- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合 - 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发 - 新增启动进度条组件,展示启动阶段进度与状态 - 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估 - 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5 - 支持uniacid租户隔离的持久化规则存储
979 lines
34 KiB
JavaScript
979 lines
34 KiB
JavaScript
/**
|
||
* 全局隐私协议管理器(单例)
|
||
*
|
||
* 职责:
|
||
* 1. 统一判断是否需要弹窗(storage + 平台级隐私状态)
|
||
* 2. 通过 uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件弹窗
|
||
* 3. 用户同意后写 storage,拒绝后不写(允许下次再弹)
|
||
* 4. 只弹一次:同意后永不再弹
|
||
* 5. 事件补偿:若 emit 时无监听者,保留 _pendingShow,页面 mounted 时补偿弹窗
|
||
*
|
||
* 平台差异:
|
||
* H5: 仅查 storage
|
||
* 微信小程序: wx.getPrivacySetting() 查平台级隐私状态
|
||
* 鸿蒙元服务: has.getPrivacySetting() 查平台级隐私状态
|
||
*
|
||
* 事件:
|
||
* uni.$emit('privacy:show') — 通知当前页面组件弹窗
|
||
* uni.$emit('privacy:agreed') — 用户已同意
|
||
* uni.$emit('privacy:disagreed') — 用户已拒绝
|
||
* uni.$emit('privacy:closeOthers') — 通知其他页面关闭弹窗
|
||
* uni.$emit('privacy:rules-updated') — URL/路由规则被应用/融合后触发 (远程下发后热更新)
|
||
*
|
||
* 使用:
|
||
* App.vue onLaunch (boot完成后): await PrivacyManager.show()
|
||
* privacy-popup.vue: mounted 中 uni.$on('privacy:show') + popPendingShow() 补偿
|
||
* privacy-popup.vue: handleAgree 中 PrivacyManager.agree()
|
||
* privacy-popup.vue: handleDisagree 中 PrivacyManager.disagree()
|
||
*/
|
||
|
||
const STORAGE_KEY = 'privacyAgreed';
|
||
|
||
// 内部状态
|
||
let _needShow = false; // 是否需要弹窗
|
||
let _hasChecked = false; // 是否已执行过 check
|
||
let _pendingShow = false; // emit 时无监听者的补偿标志
|
||
// URL/路由规则已经做过决策 (避免鸿蒙兜底在白名单模式下错误弹窗)
|
||
let _urlRuleDecided = false;
|
||
|
||
// ============================================================
|
||
// 弹窗"实际显示"状态 (由 privacy-popup 通过 privacy:popup-state 事件同步)
|
||
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
|
||
// 双阶段弹窗重评估要用这个真实状态判断
|
||
let _popupActuallyShowing = false
|
||
|
||
// ============================================================
|
||
/**
|
||
* 同步读取 storage + 平台决策 (H5 + 不依赖 storage 的情况)
|
||
* 用于"已同意过"的快速路径, 不走 async checkPrivacy
|
||
* @returns {{needAuthorization: boolean, fromCache: boolean}}
|
||
*/
|
||
function checkPrivacySync() {
|
||
// H5 + 其他非微信/快应用/鸿蒙平台: storage 决定一切
|
||
const storageAgreed = getStorageAgreed()
|
||
return {
|
||
needAuthorization: !storageAgreed,
|
||
fromCache: storageAgreed,
|
||
}
|
||
}
|
||
|
||
// URL/路由级别隐私弹窗规则
|
||
// mode:
|
||
// 'all' - 始终走弹窗检查(默认, 向后兼容)
|
||
// 'only' - 白名单: 只有命中 patterns 的页面才走弹窗检查, 其他场景一律跳过
|
||
// 'skip' - 黑名单: 命中 patterns 的页面跳过弹窗, 其他场景走弹窗检查
|
||
// 匹配方式: 子串匹配
|
||
// H5: pathname + search + hash
|
||
// 小程序/鸿蒙: 当前页面 route + query
|
||
// patterns 为空数组时:
|
||
// 'all' → 无规则, 走弹窗检查
|
||
// 'only' → 视为"没有需要弹窗的页面", 一律跳过
|
||
// 'skip' → 视为"没有需要跳过的页面", 全部走弹窗检查
|
||
// ============================================================
|
||
const PRIVACY_URL_RULES = {
|
||
mode: 'all',
|
||
patterns: [],
|
||
}
|
||
|
||
/**
|
||
* 读取 storage 中的同意状态
|
||
*/
|
||
function getStorageAgreed() {
|
||
try {
|
||
return !!uni.getStorageSync(STORAGE_KEY);
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 微信小程序: 查询平台级隐私设置
|
||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||
*/
|
||
function getWxPrivacySetting() {
|
||
return new Promise((resolve) => {
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
if (wx.getPrivacySetting) {
|
||
wx.getPrivacySetting({
|
||
success: (res) => {
|
||
resolve({
|
||
needAuthorization: !!res.needAuthorization,
|
||
privacyContractName: res.privacyContractName || ''
|
||
});
|
||
},
|
||
fail: () => resolve({ needAuthorization: !getStorageAgreed() })
|
||
});
|
||
} else {
|
||
// 低版本基础库,回退到 storage
|
||
resolve({ needAuthorization: !getStorageAgreed() });
|
||
}
|
||
} catch (e) {
|
||
resolve({ needAuthorization: !getStorageAgreed() });
|
||
}
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
resolve({ needAuthorization: !getStorageAgreed() });
|
||
// #endif
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 快应用: 查询平台级隐私设置
|
||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||
*/
|
||
function getQuickAppPrivacySetting() {
|
||
return new Promise((resolve) => {
|
||
// #ifdef QUICKAPP-WEBVIEW
|
||
try {
|
||
const has = require('@system.has');
|
||
if (has && has.getPrivacySetting) {
|
||
has.getPrivacySetting({
|
||
success: (res) => {
|
||
resolve({
|
||
needAuthorization: !!res.needAuthorization,
|
||
privacyContractName: res.privacyContractName || ''
|
||
});
|
||
},
|
||
fail: () => resolve({ needAuthorization: !getStorageAgreed() })
|
||
});
|
||
return;
|
||
}
|
||
} catch (e) {}
|
||
// #endif
|
||
// 非快应用环境: 仅查 storage
|
||
resolve({ needAuthorization: !getStorageAgreed() });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 鸿蒙元服务: 查询平台级隐私设置
|
||
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
|
||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||
*/
|
||
function getHarmonyPrivacySetting() {
|
||
return new Promise((resolve) => {
|
||
// #ifdef MP-HARMONY
|
||
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
|
||
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
|
||
resolve({ needAuthorization: !getStorageAgreed() });
|
||
// #endif
|
||
// #ifndef MP-HARMONY
|
||
resolve({ needAuthorization: !getStorageAgreed() });
|
||
// #endif
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 综合查询: 平台级隐私状态 + storage 状态
|
||
* 任一判断为"需要授权"则弹窗
|
||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||
*/
|
||
async function checkPrivacy() {
|
||
// 微信小程序: 调 wx.getPrivacySetting
|
||
// 注意: uniapp 不支持 #ifndef 多 || 与嵌套 #ifndef,
|
||
// 改用回退策略 - 仅微信走异步 API, 其他平台走 sync checkPrivacySync()
|
||
// 调用方 (show) 已经对 storage 已同意做了 sync 短路 (参见 checkPrivacySync()),
|
||
// 这里只处理 storage 未同意情况下的 async API 调用。
|
||
// #ifdef MP-WEIXIN
|
||
if (typeof wx !== 'undefined' && wx.getPrivacySetting) {
|
||
return await getWxPrivacySetting();
|
||
}
|
||
// #endif
|
||
|
||
// 快应用: 调 has.getPrivacySetting
|
||
// #ifdef QUICKAPP-WEBVIEW
|
||
if (typeof has !== 'undefined' && has.getPrivacySetting) {
|
||
return await getQuickAppPrivacySetting();
|
||
}
|
||
// #endif
|
||
|
||
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
||
// H5 / 其他平台: 仅查 storage
|
||
return checkPrivacySync();
|
||
}
|
||
|
||
/**
|
||
* 取当前页面的"路径字符串"
|
||
* H5: location.pathname + search + hash
|
||
* 微信小程序: 当前页面 route + query (例: pages/index/index?id=1)
|
||
* 鸿蒙元服务: 当前页面 route + query (同小程序)
|
||
* @param {Object} [fallback] - 兜底路径, 用于 onLaunch 等还没 getCurrentPages 的时机
|
||
* H5: { pathname, search, hash }
|
||
* 小程序/鸿蒙: { route, query }
|
||
* @returns {string}
|
||
*/
|
||
function getCurrentPathString(fallback) {
|
||
// H5
|
||
// #ifdef H5
|
||
// H5 模式下优先用 getCurrentPages() 拿 uniapp route (history/hash 都适配)
|
||
// 兜底: window.location.pathname + search + hash
|
||
try {
|
||
if (typeof getCurrentPages === 'function') {
|
||
const pages = getCurrentPages()
|
||
const cur = pages[pages.length - 1]
|
||
if (cur && cur.route) {
|
||
const route = cur.route
|
||
const query = cur.options
|
||
let queryStr = ''
|
||
if (query && typeof query === 'object' && Object.keys(query).length) {
|
||
queryStr = '?' + Object.keys(query)
|
||
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
|
||
.map(k => k + '=' + encodeURIComponent(query[k]))
|
||
.join('&')
|
||
}
|
||
return '/' + String(route).replace(/^\/+/, '') + queryStr
|
||
}
|
||
}
|
||
} catch (e) { /* ignore, fall through to location */ }
|
||
// 兜底: window.location (含 history 路由 + base)
|
||
if (typeof window !== 'undefined' && window.location) {
|
||
const pathname = (fallback && fallback.pathname) || window.location.pathname || ''
|
||
const search = (fallback && fallback.search !== undefined ? fallback.search : window.location.search) || ''
|
||
const hash = (fallback && fallback.hash !== undefined ? fallback.hash : window.location.hash) || ''
|
||
return pathname + search + hash
|
||
}
|
||
return ''
|
||
// #endif
|
||
|
||
// 微信小程序 / 鸿蒙元服务 / 其他支持 getCurrentPages 的端
|
||
// #ifndef H5
|
||
try {
|
||
const pages = (typeof getCurrentPages === 'function') ? getCurrentPages() : []
|
||
let route = ''
|
||
let query = null
|
||
const cur = pages[pages.length - 1]
|
||
if (cur) {
|
||
route = cur.route || ''
|
||
query = cur.options
|
||
}
|
||
// 兜底: onLaunch 时 getCurrentPages 还没页面, 用 fallback
|
||
if (!route && fallback) {
|
||
route = fallback.route || ''
|
||
query = fallback.query
|
||
}
|
||
if (!route) return ''
|
||
let queryStr = ''
|
||
if (query && typeof query === 'object' && Object.keys(query).length) {
|
||
queryStr = '?' + Object.keys(query)
|
||
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
|
||
.map(k => k + '=' + encodeURIComponent(query[k]))
|
||
.join('&')
|
||
}
|
||
return '/' + String(route).replace(/^\/+/, '') + queryStr
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
/**
|
||
* 判断 path 是否命中 patterns 之一
|
||
* 匹配规则 (双通道, 任一命中即算命中):
|
||
* 1. 子串匹配: path.indexOf(p) !== -1 (兼容 /pages/index/index 这种完整路径)
|
||
* 2. 结尾匹配: path 以 p 结尾 (兼容 subPackage 路径 /pages_tool/member/index 命中 "member/index")
|
||
* 这样业务侧既可以写完整路径也可以写相对路径
|
||
*/
|
||
function matchAny(path, patterns) {
|
||
if (!path || !Array.isArray(patterns)) return false
|
||
return patterns.some((p) => {
|
||
if (!p || typeof p !== 'string') return false
|
||
return path.indexOf(p) !== -1 || path.endsWith(p)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 根据 PRIVACY_URL_RULES 判断当前 URL/路由是否要走弹窗检查
|
||
* @param {Object} [fallback] - 传给 getCurrentPathString 的兜底路径
|
||
* @returns {boolean} true=本次应走弹窗检查, false=本次跳过
|
||
*/
|
||
function shouldShowByUrlRule(fallback) {
|
||
const mode = PRIVACY_URL_RULES.mode || 'all'
|
||
const patterns = PRIVACY_URL_RULES.patterns || []
|
||
if (!Array.isArray(patterns) || !patterns.length) {
|
||
// 空 patterns 时:
|
||
// 'all' → 无规则, 走检查
|
||
// 'only' → 没有需要弹窗的页面, 一律跳过
|
||
// 'skip' → 没有需要跳过的页面, 全部走检查
|
||
return mode === 'all' || mode === 'skip'
|
||
}
|
||
const path = getCurrentPathString(fallback)
|
||
const hit = matchAny(path, patterns)
|
||
if (mode === 'only') return hit
|
||
if (mode === 'skip') return !hit
|
||
return true // mode === 'all'
|
||
}
|
||
|
||
/**
|
||
* 覆盖 URL/路由级别隐私弹窗规则
|
||
* @param {Object} rules
|
||
* @param {'all'|'only'|'skip'} [rules.mode='all']
|
||
* @param {string[]} [rules.patterns=[]]
|
||
*/
|
||
function setUrlRules(rules) {
|
||
if (!rules || typeof rules !== 'object') return
|
||
if (rules.mode && ['all', 'only', 'skip'].indexOf(rules.mode) !== -1) {
|
||
PRIVACY_URL_RULES.mode = rules.mode
|
||
}
|
||
if (Array.isArray(rules.patterns)) {
|
||
PRIVACY_URL_RULES.patterns = rules.patterns.slice()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 持久化键 (与 privacyAgreed 同维度, 方便 uniacid 隔离)
|
||
* 由 App.vue 在 setUrlRules 前注入前缀: privacy_url_rules_<uniacid>
|
||
*/
|
||
let _storageKey = 'privacy_url_rules'
|
||
|
||
/**
|
||
* 从 storage 读取上次持久化的最终规则
|
||
* 自动过滤 __merge/__source/__ts 元信息, 仅返回纯规则
|
||
* @returns {Object|null}
|
||
*/
|
||
function loadPersistedRules() {
|
||
try {
|
||
const raw = uni.getStorageSync(_storageKey)
|
||
if (!raw) return null
|
||
const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw
|
||
if (!obj || typeof obj !== 'object') return null
|
||
// 过滤元信息
|
||
return {
|
||
mode: obj.mode,
|
||
patterns: Array.isArray(obj.patterns) ? obj.patterns.slice() : [],
|
||
}
|
||
} catch (e) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 持久化当前最终规则
|
||
* 携带 merge 字段 + 时间戳 + 签名, 方便后续热修复/审计/防篡改
|
||
* @param {Object} rules
|
||
* @param {Object} [meta] - { merge?: string, source?: string, sign?: string }
|
||
*/
|
||
function persistRules(rules, meta) {
|
||
try {
|
||
const final = Object.assign({}, rules || PRIVACY_URL_RULES, {
|
||
__merge: (meta && meta.merge) || (rules && rules.__merge) || 'replace',
|
||
__source: (meta && meta.source) || 'base',
|
||
__ts: Date.now(),
|
||
__sign: (meta && meta.sign) || (rules && rules.__sign) || '',
|
||
})
|
||
uni.setStorageSync(_storageKey, final)
|
||
} catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* 计算远程 patch 的签名 (前端简易实现, 与后端 hash_hmac 对齐)
|
||
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||
* 前端: 用 btoa 或 uni.arrayBufferToBase64 模拟 (弱校验, 防简单篡改)
|
||
* 真正的强校验建议用 crypto-js (引入成本)
|
||
* @param {Object} patch - 远程下发对象
|
||
* @param {string} secret
|
||
* @returns {string}
|
||
*/
|
||
function computeSign(patch, secret) {
|
||
if (!secret || !patch) return ''
|
||
try {
|
||
const payload = JSON.stringify(patch)
|
||
let _b64 = ''
|
||
if (typeof btoa === 'function') {
|
||
_b64 = btoa(payload)
|
||
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||
const _encoder = (typeof TextEncoder !== 'undefined') ? new TextEncoder() : null
|
||
if (_encoder) {
|
||
_b64 = uni.arrayBufferToBase64(_encoder.encode(payload).buffer)
|
||
}
|
||
}
|
||
if (!_b64) return ''
|
||
// 取前 32 位作为 signature (与 lang-loader.js validateSign 保持一致)
|
||
return _b64.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验远程 patch
|
||
* - signSecret 未配置 → 通过 (向后兼容)
|
||
* - patch.sign 缺失 → 通过 (后端未启用签名)
|
||
* - patch.sign 不匹配 → 拒绝 (返回 false)
|
||
* @param {Object} patch - 远程下发 { mode, patterns, merge, sign? }
|
||
* @returns {boolean}
|
||
*/
|
||
function verifyRemotePatch(patch) {
|
||
if (!patch || typeof patch !== 'object') return false
|
||
// 业务侧未配置密钥 → 跳过校验
|
||
if (!_signSecret) return true
|
||
// patch 未带 sign 字段 → 后端未启用签名, 跳过校验 (兼容旧版本后端)
|
||
if (!patch.sign) return true
|
||
const expected = computeSign(patch, _signSecret)
|
||
const ok = (expected && expected === patch.sign)
|
||
if (!ok) {
|
||
console.warn('[privacy] remote patch sign invalid', {
|
||
expected: expected.slice(0, 8),
|
||
received: String(patch.sign).slice(0, 8),
|
||
})
|
||
}
|
||
return ok
|
||
}
|
||
|
||
/**
|
||
* 合并本地 base 与远程 patch
|
||
* mode:
|
||
* patch.mode 缺失/非合法值 → 用 base.mode
|
||
* patch.mode 合法 → 用 patch.mode (远程覆盖)
|
||
* patterns:
|
||
* merge = 'replace' → 用 patch.patterns (远程整体替换, 默认)
|
||
* merge = 'union' → base + patch 去重合并 (子串匹配: 较长的子串已包含较短, 保留较长)
|
||
* merge = 'intersect'→ 仅保留同时出现在 base 和 patch 的项
|
||
* merge 缺失/未知 → 退化为 'replace'
|
||
* patch.patterns 缺失/非数组 → 用 base.patterns
|
||
*
|
||
* @param {Object} base - 本地声明的基础规则 { mode, patterns }
|
||
* @param {Object} patch - 远程下发的补丁 { mode?, patterns?, merge? }
|
||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||
*/
|
||
function mergeRules(base, patch) {
|
||
const baseMode = (base && base.mode) || 'all'
|
||
const basePatterns = (base && Array.isArray(base.patterns)) ? base.patterns.slice() : []
|
||
|
||
if (!patch || typeof patch !== 'object') {
|
||
return { mode: baseMode, patterns: basePatterns, merge: 'replace' }
|
||
}
|
||
|
||
// mode: 远程值合法则覆盖
|
||
let mode = baseMode
|
||
if (patch.mode && ['all', 'only', 'skip'].indexOf(patch.mode) !== -1) {
|
||
mode = patch.mode
|
||
}
|
||
|
||
// patterns + merge
|
||
const merge = (patch.merge && ['replace', 'union', 'intersect'].indexOf(patch.merge) !== -1)
|
||
? patch.merge
|
||
: 'replace'
|
||
|
||
let patterns
|
||
if (!Array.isArray(patch.patterns)) {
|
||
patterns = basePatterns
|
||
} else if (merge === 'replace') {
|
||
patterns = patch.patterns.slice()
|
||
} else if (merge === 'union') {
|
||
patterns = dedupeUnion(basePatterns, patch.patterns)
|
||
} else { // intersect
|
||
patterns = basePatterns.filter(p => patch.patterns.indexOf(p) !== -1)
|
||
}
|
||
|
||
return { mode, patterns, merge }
|
||
}
|
||
|
||
/**
|
||
* 去重合并 (子串匹配: 若 a 包含 b, b 可视为冗余)
|
||
* 简单实现: 直接 concat 后去重, 复杂度可接受 (规则通常 < 50 条)
|
||
*/
|
||
function dedupeUnion(a, b) {
|
||
const all = (a || []).concat(b || [])
|
||
const seen = new Set()
|
||
const out = []
|
||
for (const p of all) {
|
||
if (typeof p !== 'string' || !p) continue
|
||
if (seen.has(p)) continue
|
||
seen.add(p)
|
||
out.push(p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* 应用"已合并规则"到 PRIVACY_URL_RULES
|
||
* 触发 'privacy:rules-updated' 事件, 业务侧可监听 (例如想热更新弹窗)
|
||
* @param {Object} rules
|
||
* @param {Object} [meta] - { source: 'base'|'remote'|'persisted', patch?: Object }
|
||
*/
|
||
function applyMergedRules(rules, meta) {
|
||
if (!rules || typeof rules !== 'object') return
|
||
const prevMode = PRIVACY_URL_RULES.mode
|
||
const prevPatterns = PRIVACY_URL_RULES.patterns.slice()
|
||
// 记录"应用前 popup 实际显示状态", 用于 reEvaluate 决策
|
||
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
|
||
// 必须用 _popupActuallyShowing (由 privacy-popup 主动同步)
|
||
const prevPopupShowing = _popupActuallyShowing
|
||
setUrlRules(rules)
|
||
persistRules(rules, {
|
||
merge: rules && rules.merge,
|
||
source: meta && meta.source,
|
||
sign: meta && meta.patch && meta.patch.sign,
|
||
})
|
||
try {
|
||
uni.$emit && uni.$emit('privacy:rules-updated', {
|
||
mode: PRIVACY_URL_RULES.mode,
|
||
patterns: PRIVACY_URL_RULES.patterns.slice(),
|
||
source: (meta && meta.source) || 'remote',
|
||
patch: meta && meta.patch,
|
||
})
|
||
} catch (e) {}
|
||
|
||
// 双阶段弹窗: 远程 patch 在 show() 之后到达时, 强制重评估
|
||
// 触发条件:
|
||
// 1. patch 来源是 'remote' (非 'base')
|
||
// 2. 规则真的变了 (mode/patterns 之一不同)
|
||
// 3. show() 已经调用过 (_hasChecked=true)
|
||
// 行为:
|
||
// - 旧规则没弹 → 新规则要弹 → emit show (popup 重弹)
|
||
// - 旧规则在弹 → 新规则不该弹 → emit dismiss (popup 关闭)
|
||
const _source = (meta && meta.source) || 'remote'
|
||
const _rulesChanged = (
|
||
prevMode !== PRIVACY_URL_RULES.mode ||
|
||
prevPatterns.length !== PRIVACY_URL_RULES.patterns.length ||
|
||
prevPatterns.some((p, i) => p !== PRIVACY_URL_RULES.patterns[i])
|
||
)
|
||
if (_source === 'remote' && _rulesChanged && _hasChecked) {
|
||
// dev-only 日志, 受 __PRIVACY_DEBUG__ 开关控制 (默认开, 生产可通过 DefinePlugin 关)
|
||
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
|
||
console.log('[privacy] reEvaluate trigger: prevMode=' + prevMode + ' newMode=' + PRIVACY_URL_RULES.mode + ' prevPopupShowing=' + prevPopupShowing)
|
||
}
|
||
// 异步执行, 不阻塞 apply 流程
|
||
setTimeout(() => { reEvaluateAfterRulesUpdated(prevPopupShowing) }, 0)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 规则更新后的重评估
|
||
* 场景: 远程 patch 在 show() 之后到达, 本次启动弹窗决策是基于旧规则做的
|
||
* 现在新规则来了, 必须重评估
|
||
* 步骤:
|
||
* 1. 用 prevPopupShowing (popup 实际显示状态) + shouldShowByUrlRule (应用后规则 + 当前 URL) 算差量
|
||
* 2. prevPopupShowing=false, shouldShow=true → emit show (popup 重弹)
|
||
* 3. prevPopupShowing=true, shouldShow=false → emit dismiss (popup 关闭)
|
||
* 4. 其他情况: 状态不变, 不 emit (避免无谓的弹窗抖动)
|
||
* 边界: 用户已同意过 storage → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
|
||
* @param {boolean} prevPopupShowing - 应用规则前的 popup 实际显示状态
|
||
*/
|
||
function reEvaluateAfterRulesUpdated(prevPopupShowing) {
|
||
// dev-only 日志
|
||
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
|
||
console.log('[privacy] reEvaluate called prevPopup=' + prevPopupShowing + ' storageAgreed=' + getStorageAgreed())
|
||
}
|
||
if (getStorageAgreed()) {
|
||
// 已同意过 → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
|
||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'noop', reason: 'already-agreed' }) } catch (e) {}
|
||
return
|
||
}
|
||
// 用户未同意 → 走规则决策
|
||
const shouldShow = shouldShowByUrlRule()
|
||
const wasShowing = !!prevPopupShowing
|
||
if (!wasShowing && shouldShow) {
|
||
// 旧规则没弹, 新规则要弹 → 重新设置 _pendingShow + emit show
|
||
_needShow = true
|
||
_pendingShow = true
|
||
_hasChecked = true
|
||
_urlRuleDecided = false
|
||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'show', reason: 'rules-updated' }) } catch (e) {}
|
||
} else if (wasShowing && !shouldShow) {
|
||
// 旧规则在弹, 新规则不该弹 → 撤销 _pendingShow + emit dismiss
|
||
_needShow = false
|
||
_pendingShow = false
|
||
_hasChecked = true
|
||
_urlRuleDecided = true
|
||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'dismiss', reason: 'rules-updated' }) } catch (e) {}
|
||
}
|
||
// 其他情况: 状态不变, 不 emit
|
||
}
|
||
|
||
/**
|
||
* 一站式: 应用本地 base → 若有远程 patch 则融合 → 持久化 → 触发事件
|
||
* 无远程 patch 时, base 直接生效 (向后兼容)
|
||
* @param {Object} base - 本地基础规则
|
||
* @param {Object} [patch] - 远程下发的 patch (可空)
|
||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||
*/
|
||
function applyWithMerge(base, patch) {
|
||
// 签名校验: 远程 patch 不合法 → 静默拒绝, 退回 base
|
||
// (业务侧可通过 PrivacyManager.verifyRemotePatch() 单独调用)
|
||
if (patch && !verifyRemotePatch(patch)) {
|
||
console.warn('[privacy] applyWithMerge: remote patch sign invalid, fallback to base')
|
||
const merged = mergeRules(base, null)
|
||
applyMergedRules(merged, { source: 'base', patch: null })
|
||
return merged
|
||
}
|
||
const merged = mergeRules(base, patch)
|
||
applyMergedRules(merged, { source: patch ? 'remote' : 'base', patch })
|
||
return merged
|
||
}
|
||
|
||
// 本地 base 规则 (由 App.vue 通过 setBaseRules 登记)
|
||
// 供 store/index.js 的 init action 在收到远程 patch 时读取
|
||
let _baseRules = { mode: 'all', patterns: [] }
|
||
|
||
// 外部注入的 readyWaiters (由 config-external.js ConfigExternal 持有)
|
||
// 兼容老用法: 业务侧可直接传 readyGuard, 也可走 ConfigExternal.setReadyWaiters 通道
|
||
let _externalReadyGuard = null
|
||
let _externalBootTimeout = 6000
|
||
let _externalWaitChannels = { lang: true, theme: true, themeOverride: true, remoteConfig: true }
|
||
|
||
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||
// 业务侧通过 setSignSecret(secret) 或 privacyConfig.signSecret 注入
|
||
let _signSecret = ''
|
||
|
||
// 远程 patch 签名 (在持久化时被写入 storage 的 __sign 字段, 供后续审计/防回滚)
|
||
// 注意: 当前实现不做增量校验, 只在 verifyRemotePatch 内单次校验
|
||
// 如需增量校验 (防止回滚到旧 patch), 可在此维护 _lastPersistedSign
|
||
// 每次 applyWithMerge 持久化时记录, 下次启动时与远程 patch 的 sign 字段比对
|
||
let _lastPersistedSign = ''
|
||
|
||
// ============================================================
|
||
// 监听 privacy:popup-state 事件 (由 privacy-popup.vue 主动同步弹窗显示状态)
|
||
// 用于双阶段弹窗的精确重评估 (reEvaluateAfterRulesUpdated)
|
||
// 这里挂一个全局监听, 模块加载时就生效
|
||
// ============================================================
|
||
;(function _installPopupStateListener() {
|
||
try {
|
||
if (typeof uni === 'undefined' || !uni.$on) return
|
||
uni.$on('privacy:popup-state', (info) => {
|
||
if (info && typeof info.showing === 'boolean') {
|
||
_popupActuallyShowing = info.showing
|
||
}
|
||
})
|
||
} catch (e) {}
|
||
})()
|
||
|
||
const PrivacyManager = {
|
||
/**
|
||
* 同步检查 storage 中的同意状态(快速判断,不查平台)
|
||
* @returns {boolean} true=需要弹窗, false=不需要
|
||
*/
|
||
check() {
|
||
_needShow = !getStorageAgreed();
|
||
_hasChecked = true;
|
||
return _needShow;
|
||
},
|
||
|
||
/**
|
||
* 同步标记本次跳过弹窗(不查 storage、不写 storage、不弹窗)
|
||
* 必须在 page mounted 之前调用,避免 onLaunch await 让出事件循环后
|
||
* privacy-popup.vue 的 consumePendingShow 走兜底分支把弹窗弹出来
|
||
* 用于 H5 浏览器直访特定 URL(子串匹配)时不让隐私弹窗挡住
|
||
*/
|
||
markSkipped() {
|
||
_needShow = false;
|
||
_hasChecked = true;
|
||
_pendingShow = false;
|
||
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
|
||
},
|
||
|
||
/**
|
||
* 触发弹窗(如果需要)
|
||
* 由 App.vue onLaunch 在 boot 完成后调用
|
||
* 异步查询平台隐私状态,设置 _pendingShow 标志
|
||
* 页面组件 mounted 时通过 consumePendingShow() 消费标志并弹窗
|
||
* 不直接 emit 事件,避免 emit + 补偿双触发
|
||
*
|
||
* @param {Object} [options]
|
||
* @param {boolean} [options.skip=false] true=本次跳过弹窗(不写 storage,下次正常访问其他页面还会弹)
|
||
* @param {Object} [options.urlFallback] 首次启动 onLaunch 时 getCurrentPages 还没有页面,
|
||
* 可传入 onLaunch options 兜底:
|
||
* H5: { pathname, search, hash }
|
||
* 小程序/鸿蒙: { route, query }
|
||
* @param {Promise|Function} [options.readyGuard] 外部 readiness 守卫 (例如 i18n/theme),
|
||
* show() 会 await 它再继续, 保证弹窗在外部资源就绪后才决策.
|
||
* 超时保护: 业务侧应保证 guard 自身有超时, 否则会一直 hang.
|
||
*/
|
||
async show(options = {}) {
|
||
const fallback = options && options.urlFallback ? options.urlFallback : null
|
||
|
||
// 外部 readiness 守卫: 等业务侧 promise 再继续
|
||
// 优先级: options.readyGuard (调用方显式传) > _externalReadyGuard (ConfigExternal 注入)
|
||
// 场景: App.vue onLaunch 把 i18n/theme/remote-config 的 await 链作为 guard 传入
|
||
// 这样无论 show() 在哪里调用, 都保证弹窗决策时外部资源已就绪
|
||
const _guard = (options && typeof options.readyGuard !== 'undefined')
|
||
? options.readyGuard
|
||
: _externalReadyGuard
|
||
if (typeof _guard !== 'undefined' && _guard !== null) {
|
||
try {
|
||
if (_guard && typeof _guard.then === 'function') {
|
||
await _guard
|
||
} else if (typeof _guard === 'function') {
|
||
await _guard()
|
||
}
|
||
} catch (e) {
|
||
console.warn('[privacy] readyGuard fail', e)
|
||
}
|
||
}
|
||
|
||
// 主动跳过: 不查 storage、不写 storage、不弹窗
|
||
// 用于 H5 浏览器直接访问特定 URL(子串匹配)时不让隐私弹窗挡住
|
||
if (options && options.skip) {
|
||
_needShow = false;
|
||
_hasChecked = true;
|
||
_pendingShow = false;
|
||
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
|
||
return;
|
||
}
|
||
|
||
// URL/路由规则判断
|
||
// mode='only' 且未命中 patterns → 跳过
|
||
// mode='skip' 且命中 patterns → 跳过
|
||
// 其他情况 → 继续走 storage + 平台检查
|
||
if (!shouldShowByUrlRule(fallback)) {
|
||
_needShow = false;
|
||
_hasChecked = true;
|
||
_pendingShow = false;
|
||
_urlRuleDecided = true; // 已决策, 阻止鸿蒙兜底强制弹窗
|
||
return;
|
||
}
|
||
// URL 规则允许弹, 重置 urlRuleDecided 留给鸿蒙兜底分支判断
|
||
_urlRuleDecided = false;
|
||
|
||
const _storageAgreed = getStorageAgreed();
|
||
|
||
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
||
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
||
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
|
||
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
||
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
||
|
||
_needShow = result.needAuthorization;
|
||
_hasChecked = true;
|
||
|
||
if (!_needShow) {
|
||
return;
|
||
}
|
||
|
||
// 设置待弹窗标志
|
||
// 已 mounted 的页面组件通过事件接收
|
||
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
|
||
_pendingShow = true;
|
||
|
||
// 通知已 mounted 的页面组件(如果有监听者)
|
||
try { uni.$emit('privacy:show'); } catch (e) {}
|
||
|
||
// 若组件已接收事件并弹窗, 会在 handlePrivacyShow 中调 consumePendingShow 清除 _pendingShow
|
||
// 若无监听者, _pendingShow 保持 true, 等页面 mounted 时补偿
|
||
},
|
||
|
||
/**
|
||
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
||
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
||
*
|
||
* 鸿蒙元服务兼容:
|
||
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
||
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
||
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
||
* @returns {boolean} true=需要弹窗,false=不需要
|
||
*/
|
||
consumePendingShow() {
|
||
if (_pendingShow && _needShow) {
|
||
_pendingShow = false;
|
||
return true;
|
||
}
|
||
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
||
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
||
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
||
_pendingShow = false;
|
||
_needShow = true;
|
||
_hasChecked = true;
|
||
return true;
|
||
}
|
||
_pendingShow = false;
|
||
return false;
|
||
},
|
||
|
||
/**
|
||
* 用户同意:写 storage,标记不再弹
|
||
* 由 privacy-popup.vue 的 handleAgree 调用
|
||
*/
|
||
agree() {
|
||
try { uni.setStorageSync(STORAGE_KEY, true); } catch (e) {}
|
||
_needShow = false;
|
||
_pendingShow = false;
|
||
try { uni.$emit('privacy:agreed'); } catch (e) {}
|
||
},
|
||
|
||
/**
|
||
* 用户拒绝:不写 storage,允许下次再弹
|
||
* 由 privacy-popup.vue 的 handleDisagree 调用
|
||
*/
|
||
disagree() {
|
||
_needShow = false;
|
||
_pendingShow = false;
|
||
try { uni.$emit('privacy:disagreed'); } catch (e) {}
|
||
},
|
||
|
||
/**
|
||
* 当前是否需要弹窗(供页面查询)
|
||
* @returns {boolean}
|
||
*/
|
||
needShow() {
|
||
return _needShow;
|
||
},
|
||
|
||
/**
|
||
* 是否已执行过 check
|
||
* @returns {boolean}
|
||
*/
|
||
hasChecked() {
|
||
return _hasChecked;
|
||
},
|
||
|
||
/**
|
||
* 配置 URL/路由级别隐私弹窗规则
|
||
* @param {Object} rules
|
||
* @param {'all'|'only'|'skip'} [rules.mode='all']
|
||
* 'all' - 始终走弹窗检查(默认)
|
||
* 'only' - 白名单: 只有命中 patterns 才弹
|
||
* 'skip' - 黑名单: 命中 patterns 跳过
|
||
* @param {string[]} [rules.patterns=[]]
|
||
* 子串匹配, H5: pathname+search+hash, 小程序/鸿蒙: route+query
|
||
*/
|
||
setUrlRules(rules) {
|
||
setUrlRules(rules)
|
||
},
|
||
|
||
/**
|
||
* 当前 URL/路由规则下是否要走弹窗检查
|
||
* @param {Object} [fallback] 兜底路径(同 show 的 urlFallback)
|
||
* @returns {boolean}
|
||
*/
|
||
shouldShowByUrlRule(fallback) {
|
||
return shouldShowByUrlRule(fallback)
|
||
},
|
||
|
||
/**
|
||
* 应用本地 base + 远程 patch 的融合结果
|
||
* - patch=null/缺 → 仅应用 base (并持久化)
|
||
* - patch 存在 → 走 mergeRules 融合
|
||
* 应用后 emit 'privacy:rules-updated'
|
||
* @param {Object} base 本地基础规则 { mode, patterns }
|
||
* @param {Object} [patch] 远程下发 { mode?, patterns?, merge? }
|
||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||
*/
|
||
applyWithMerge(base, patch) {
|
||
return applyWithMerge(base, patch)
|
||
},
|
||
|
||
/**
|
||
* 仅合并 (不写入), 方便上层预览或 dry-run
|
||
* @param {Object} base
|
||
* @param {Object} patch
|
||
*/
|
||
mergeRules(base, patch) {
|
||
return mergeRules(base, patch)
|
||
},
|
||
|
||
/**
|
||
* 读取上次持久化的最终规则 (跨冷启动恢复)
|
||
* @returns {Object|null}
|
||
*/
|
||
loadPersistedRules() {
|
||
return loadPersistedRules()
|
||
},
|
||
|
||
/**
|
||
* 设置持久化 key (用于 uniacid 隔离), 由 App.vue 注入
|
||
* @param {string} key
|
||
*/
|
||
setStorageKey(key) {
|
||
if (typeof key === 'string' && key) _storageKey = key
|
||
},
|
||
|
||
/**
|
||
* 登记本地 base 规则 (由 App.vue 在启动时调用)
|
||
* 供后续远程下发时 mergeRules 读取
|
||
* @param {Object} rules
|
||
*/
|
||
setBaseRules(rules) {
|
||
if (!rules || typeof rules !== 'object') return
|
||
_baseRules = {
|
||
mode: rules.mode || 'all',
|
||
patterns: Array.isArray(rules.patterns) ? rules.patterns.slice() : [],
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 读取本地 base 规则 (供 store/index.js init 远程 patch 融合)
|
||
* @returns {{ mode: string, patterns: string[] }}
|
||
*/
|
||
__getBaseRules() {
|
||
return { mode: _baseRules.mode, patterns: _baseRules.patterns.slice() }
|
||
},
|
||
|
||
/**
|
||
* 注入外部 readiness 守卫 (由 config-external.js ConfigExternal 调用)
|
||
* show() 时若无 options.readyGuard, 内部使用此 promise
|
||
* @param {Promise|Function|null} guard
|
||
*/
|
||
setExternalReadyGuard(guard) {
|
||
if (guard === null) { _externalReadyGuard = null; return }
|
||
if (guard && (typeof guard.then === 'function' || typeof guard === 'function')) {
|
||
_externalReadyGuard = guard
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 注入外部 bootTimeout (由 ConfigExternal.getPrivacyBootTimeout 调用)
|
||
* @param {number} ms
|
||
*/
|
||
setExternalBootTimeout(ms) {
|
||
if (typeof ms === 'number' && ms > 0) _externalBootTimeout = ms
|
||
},
|
||
|
||
/**
|
||
* 注入 waitChannels 配置 (隐私弹窗启动期必等通道开关)
|
||
* @param {Object} channels
|
||
*/
|
||
setWaitChannels(channels) {
|
||
if (channels && typeof channels === 'object') {
|
||
_externalWaitChannels = Object.assign({}, _externalWaitChannels, channels)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 注入远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||
* 前端: 用 btoa 模拟 (弱校验, 仅防简单篡改)
|
||
* secret='' → 跳过校验 (向后兼容)
|
||
* @param {string} secret
|
||
*/
|
||
setSignSecret(secret) {
|
||
_signSecret = (typeof secret === 'string') ? secret : ''
|
||
},
|
||
|
||
/**
|
||
* 校验远程 patch (业务侧可在 store/init 之外单独调用)
|
||
* - signSecret 未配置 → true (向后兼容)
|
||
* - patch.sign 缺失 → true (后端未启用签名)
|
||
* - patch.sign 不匹配 → false
|
||
* @param {Object} patch
|
||
* @returns {boolean}
|
||
*/
|
||
verifyRemotePatch(patch) {
|
||
return verifyRemotePatch(patch)
|
||
},
|
||
|
||
/**
|
||
* 计算给定 patch 的签名 (供调试/单元测试)
|
||
* @param {Object} patch
|
||
* @param {string} [secret] - 不传则用 _signSecret
|
||
* @returns {string}
|
||
*/
|
||
computeSign(patch, secret) {
|
||
return computeSign(patch, secret || _signSecret)
|
||
},
|
||
|
||
/**
|
||
* 读取当前生效配置 (供调试/单元测试)
|
||
*/
|
||
getReadyConfig() {
|
||
return {
|
||
readyGuard: _externalReadyGuard,
|
||
bootTimeout: _externalBootTimeout,
|
||
waitChannels: Object.assign({}, _externalWaitChannels),
|
||
}
|
||
},
|
||
};
|
||
|
||
export default PrivacyManager;
|