/** * 全局隐私协议管理器(单例) * * 职责: * 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() }); }); } /** * 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API * 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径 * @returns {Promise<{needAuthorization: boolean}>} */ function getHarmonyPrivacySetting() { return new Promise((resolve) => { resolve({ needAuthorization: !getStorageAgreed() }); }); } /** * 综合查询: 平台级隐私状态 + 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 // 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 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_ */ 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 即可 // 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管) const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy(); _needShow = result.needAuthorization; _hasChecked = true; if (!_needShow) { return; } // #ifdef MP-HARMONY // 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗 // 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue // 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗 _needShow = false; _pendingShow = false; _urlRuleDecided = true; console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗'); return; // #endif // 其他平台: 设置待弹窗标志 + emit 触发自建弹窗 // 已 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 (补偿弹窗)。 * * 鸿蒙元服务(双轨制): * 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗, * 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与 * 鸿蒙系统级弹窗叠加出现"多次弹出"。 * @returns {boolean} true=需要弹窗,false=不需要 */ consumePendingShow() { // #ifdef MP-HARMONY // 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗 return false; // #endif 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;