feat: 实现隐私弹窗全链路功能,支持本地配置与远程patch融合

- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合
- 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发
- 新增启动进度条组件,展示启动阶段进度与状态
- 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估
- 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5
- 支持uniacid租户隔离的持久化规则存储
This commit is contained in:
2026-07-11 18:28:36 +08:00
parent a2066285cd
commit a913961be5
8 changed files with 1471 additions and 148 deletions

View File

@@ -94,11 +94,130 @@ export const apiConfig = {
retryDelay: 1000
};
// 隐私弹窗配置 (与 langConfig/themeConfig 同构)
// 本地兜底 + 远程 patch (来自 /api/config/init data.privacy_url_rules) 融合
// source: 'local' - 仅本地兜底, 忽略远程 patch
// 'remote' - 本地兜底 + 远程 patch 覆盖 (默认)
// 'hybrid' - 同 remote, 远程失败时退回本地
// 'remote-only' - 完全依赖远程 patch, 本地兜底为空
export const privacyConfig = {
// 本地兜底规则 (mode: 'all'|'only'|'skip', patterns: string[])
// 远程 patch 由 store/index.js 的 init action 通过 PrivacyManager.applyWithMerge() 融合
// patterns 写法:
// - 子串匹配 + 结尾匹配 (双通道), 业务侧可以写完整路径或相对路径
// - subPackage 路由 (如 /pages_tool/member/index) 用短路径 'member/index' 即可命中
urlRulesBase: {
// 场景 2: 只有首页、商品详情、个人中心才弹, 其他页面都不弹
mode: 'only',
patterns: [
// 首页 (含 subPackage 重定向后也是 pages/index/index)
'pages/index/index',
// 商品详情 (subPackage 内可能为 pages_goods/goods/detail/detail, 这里用末尾匹配)
'goods/detail/detail',
// 个人中心 (subPackage 内通常为 pages_tool/member/index)
'member/index',
],
},
// dataSource: 与 langConfig.source 同构
// 'local' 纯本地, 忽略 /api/config/init 的 privacy_url_rules
// 'remote' 本地 + 远程 patch (远程失败时仍用本地)
// 'hybrid' 同 remote, 语义别名
// 'remote-only' 必须有远程 patch, 本地空 patterns → 一律不弹
source: 'remote',
// 启动期等待通道 (弹窗必须等这些 promise 才弹)
// waitLang: Lang.boot() - i18n 数据就绪
// waitTheme: applyThemeWithRemote() - 本地 preset 应用
// waitThemeOverride: ThemeOverride.boot() - brand/light/dark 三桶 token
// waitRemoteConfig: /api/config/init (含 privacy_url_rules 远程 patch)
waitChannels: {
lang: true,
theme: true,
themeOverride: true,
remoteConfig: true,
},
// 总超时熔断 (ms): 所有等待通道超过这个时间强制放行, 不阻塞弹窗
// 取 langConfig.remote.bootTimeout (5000) 和 themeConfig.overrideRemote.bootTimeout (5000) 的最大值
// + 1000ms 余量 → 6000ms. 与原硬编码值一致, 但可通过修改此字段调整.
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大; 想"快速失败" → 调小.
bootTimeout: 6000,
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
// 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
// secret='' → 跳过校验, 接受任意 patch (向后兼容老后端)
// secret='xxx' → 校验 patch.sign, 不匹配则拒绝
// 留空默认不校验. 前端实现是 btoa 弱校验, 仅防简单篡改, 强校验建议用 crypto-js.
signSecret: '',
// 单次 /api/config/init 请求超时 (ms)
// 复用 apiConfig.timeout, 不单独配置
// remote: {
// // 远程 patch 字段名 (后端 /api/config/init 返回 data.privacy_url_rules)
// fieldPath: 'privacy_url_rules',
// // 切租户/uniacid 时强制重拉 (远端 patch 重新下发)
// refreshOnUniacidChange: true,
// // 本地缓存 key 前缀: <prefix><uniacid>
// storagePrefix: 'privacy_url_rules_',
// },
};
// 配置外置管理类
class ConfigExternal {
constructor() {
this.loadedConfigs = {};
this.loadPromises = {};
// 启动期各通道的 await promise (由 App.vue onLaunch 注入)
// 隐私弹窗 show() 时通过 PrivacyManager.getReadyWaiters() 拿到 → 串成 readyGuard
this.readyWaiters = {
lang: null,
theme: null,
themeOverride: null,
remoteConfig: null,
};
}
/**
* 设置启动期各通道的 await promise
* App.vue onLaunch 在 step 2 注入, PrivacyManager.show() 时读取
* @param {Object} waiters { lang?, theme?, themeOverride?, remoteConfig? }
*/
setReadyWaiters(waiters) {
if (!waiters || typeof waiters !== 'object') return
for (const k of Object.keys(this.readyWaiters)) {
if (waiters[k] && typeof waiters[k].then === 'function') {
this.readyWaiters[k] = waiters[k]
}
}
}
/**
* 获取隐私弹窗 readyGuard (合并所有启用的等待通道)
* 任意通道 disabled → 不等
* 所有通道都 null → 返回 Promise.resolve() (不退化)
* @param {Object} channels { lang?, theme?, themeOverride?, remoteConfig? }
* @returns {Promise}
*/
getPrivacyReadyGuard(channels) {
const cfg = (typeof channels === 'object' && channels) || (privacyConfig.waitChannels || {})
const promises = []
for (const k of ['lang', 'theme', 'themeOverride', 'remoteConfig']) {
if (cfg[k] && this.readyWaiters[k]) {
promises.push(this.readyWaiters[k])
}
}
if (!promises.length) return Promise.resolve()
return Promise.all(promises).catch(() => {})
}
/**
* 隐私弹窗总超时熔断 (ms)
* 默认取 langConfig.remote.bootTimeout 与 themeConfig.overrideRemote.bootTimeout 的最大值 + 1000ms 余量
* 业务侧可通过 privacyConfig.bootTimeout 覆盖
*/
getPrivacyBootTimeout() {
if (privacyConfig && typeof privacyConfig.bootTimeout === 'number' && privacyConfig.bootTimeout > 0) {
return privacyConfig.bootTimeout
}
const langT = (langConfig && langConfig.remote && langConfig.remote.bootTimeout) || 5000
const themeT = (themeConfig && themeConfig.overrideRemote && themeConfig.overrideRemote.bootTimeout) || 5000
return Math.max(langT, themeT) + 1000
}
/**

View File

@@ -18,6 +18,7 @@
* 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()
@@ -28,14 +29,51 @@
const STORAGE_KEY = 'privacyAgreed';
// 缓存上一次 getPrivacySetting 的结果, 避免每个组件 mounted 都重新检查
let _lastCheckResult = null;
let _lastCheckTs = 0;
// 内部状态
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 中的同意状态
@@ -132,27 +170,475 @@ function getHarmonyPrivacySetting() {
* @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
return await getWxPrivacySetting();
if (typeof wx !== 'undefined' && wx.getPrivacySetting) {
return await getWxPrivacySetting();
}
// #endif
// 快应用
// 快应用: 调 has.getPrivacySetting
// #ifdef QUICKAPP-WEBVIEW
return await getQuickAppPrivacySetting();
// #endif
// 鸿蒙元服务
// #ifdef MP-HARMONY
return await getHarmonyPrivacySetting();
if (typeof has !== 'undefined' && has.getPrivacySetting) {
return await getQuickAppPrivacySetting();
}
// #endif
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
// H5 / 其他平台: 仅查 storage
// #ifndef MP-WEIXIN || QUICKAPP-WEBVIEW || MP-HARMONY
return { needAuthorization: !getStorageAgreed() };
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 中的同意状态(快速判断,不查平台)
@@ -174,6 +660,7 @@ const PrivacyManager = {
_needShow = false;
_hasChecked = true;
_pendingShow = false;
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
},
/**
@@ -185,20 +672,68 @@ const PrivacyManager = {
*
* @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();
const result = await checkPrivacy();
// 优化: 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;
@@ -235,7 +770,8 @@ const PrivacyManager = {
}
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 同一次进程内只补一次,避免隐私弹窗被反复触发
if (!_hasChecked && !_pendingShow && !getStorageAgreed()) {
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
_pendingShow = false;
_needShow = true;
_hasChecked = true;
@@ -281,6 +817,162 @@ const PrivacyManager = {
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;

View File

@@ -36,6 +36,19 @@ function useNativeLoading() {
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
// 启动期阶段定义 (id → { percent, label })
// percent: 阶段完成的百分比 (0-100)
// label: 阶段显示文案 (i18n key 由 splash 组件消费, 也可直接用最终文案)
const STAGES = {
boot: { percent: 5, label: 'Loading...' },
lang: { percent: 30, label: 'Loading language...' },
theme: { percent: 50, label: 'Applying theme...' },
override: { percent: 65, label: 'Loading theme tokens...' },
config: { percent: 80, label: 'Fetching configuration...' },
privacy: { percent: 95, label: 'Preparing privacy dialog...' },
done: { percent: 100, label: 'Ready' },
};
class SplashState {
constructor() {
this._visible = false;
@@ -46,6 +59,14 @@ class SplashState {
this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
this._hasMounted = false; // 是否有 splash 组件 mount 过
this._mountGraceId = null; // 软隐藏兜底定时器
// 进度上报
this._stage = 'boot';
this._percent = 0;
this._stageSet = new Set(['boot']); // 已报告阶段
// 进度节流: 16ms 内的多次 reportProgress 合并成一次 emit
// 避免多通道并发时, splash 组件 re-render 风暴
this._pendingProgress = null; // 待 emit 的最新进度
this._flushTimer = null; // 节流定时器
}
show(opts = {}) {
@@ -56,6 +77,16 @@ class SplashState {
this._shownAt = Date.now();
this._pendingHide = null;
this._hasMounted = false;
this._stage = 'boot';
// 初始化时直接显示 'boot' 阶段的 5%, 而不是 0%
// 避免首帧出现"进度条 0%"的视觉突兀
this._percent = (STAGES.boot && STAGES.boot.percent) || 5;
this._stageSet = new Set(['boot']);
this._pendingProgress = null;
if (this._flushTimer) {
clearTimeout(this._flushTimer);
this._flushTimer = null;
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
@@ -89,16 +120,20 @@ class SplashState {
* - 'timeout': 硬隐藏, 立即关 (兜底用)
* - 'manual': 硬隐藏, 立即关
* @param {boolean} [opts.soft] - 强制走软隐藏
* @param {string} [opts.mode] - 离开动画模式
* - 'default': 默认 480ms 动画
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
*/
hide(opts = {}) {
const reason = opts.reason || 'manual';
// boot-complete 默认走软隐藏, 避免时序问题
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
const mode = opts.mode === 'fast' ? 'fast' : 'default';
if (useSoft) {
this._softHide({ reason });
this._softHide({ reason, mode });
} else {
this._doHide({ reason });
this._doHide({ reason, mode });
}
}
@@ -125,6 +160,19 @@ class SplashState {
}, MOUNT_GRACE_MS);
}
/**
* 真正触发软隐藏
*/
_flushPendingHide() {
const opts = this._pendingHide;
this._pendingHide = null;
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
this._doHide(opts || { reason: 'mount-completed' });
}
/**
* 实际隐藏 (硬路径)
*/
@@ -144,20 +192,10 @@ class SplashState {
if (useNativeLoading()) {
try { uni.hideLoading(); } catch (e) {}
}
this._emit('splash:hide', { reason: opts.reason || 'manual' });
}
/**
* 真正触发软隐藏
*/
_flushPendingHide() {
const opts = this._pendingHide;
this._pendingHide = null;
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
this._doHide(opts || { reason: 'mount-completed' });
this._emit('splash:hide', {
reason: opts.reason || 'manual',
mode: opts.mode || 'default',
});
}
/**
@@ -217,6 +255,111 @@ class SplashState {
try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); }
}
}
/**
* 上报启动期进度
* 节流: 16ms 内的多次 reportProgress 合并成 1 次 emit, 避免 splash re-render 风暴
* 取较大值, 不允许后退 (即使 throttle 合并也不会让进度条后退)
* @param {string} stageId - 阶段 id (STAGES 中定义)
* 'boot' | 'lang' | 'theme' | 'override' | 'config' | 'privacy' | 'done'
* 或自定义阶段 id (传 percent + label)
* @param {Object} [meta] - { percent?, label?, source?: 'channel'|'timeout'|'error' }
* 阶段完成时 percent 应该是该阶段的"完成态"百分比
*/
reportProgress(stageId, meta) {
const def = STAGES[stageId]
let percent, label
if (def) {
percent = (meta && typeof meta.percent === 'number') ? meta.percent : def.percent
label = (meta && meta.label) || def.label
} else {
// 自定义阶段
percent = (meta && typeof meta.percent === 'number') ? meta.percent : this._percent
label = (meta && meta.label) || stageId
}
// 取较大值, 不允许后退
if (percent < this._percent) percent = this._percent
this._percent = percent
this._stage = stageId
if (!this._stageSet.has(stageId)) this._stageSet.add(stageId)
// 节流 emit: 16ms 内的多次调用合并为 1 次
// 内部状态立即更新 (不延迟), 但 emit 推迟
// 注意: percent 不允许后退的逻辑已在上方处理, 所以 throttle 合并不会丢进度
const payload = {
stage: stageId,
label,
percent,
source: (meta && meta.source) || 'channel',
at: Date.now(),
}
this._pendingProgress = payload
if (this._flushTimer) return // 已有 pending, 等 timer flush
this._flushTimer = setTimeout(() => {
this._flushTimer = null
if (this._pendingProgress) {
const p = this._pendingProgress
this._pendingProgress = null
this._emit('splash:progress', p)
}
}, 16)
}
/**
* 监听进度上报
* callback({ stage, label, percent, source, at })
*
* 注意: 与 on() 不同, onProgress 是单例 listener (多个组件时只最后一个生效)
* 设计意图: splash 进度条 UI 在系统中只有一个渲染者, 不需要多播
*/
onProgress(callback) {
this._listeners['splash:progress'] = [callback]
}
/**
* 解绑进度上报 (清空 listener)
*/
offProgress() {
this._listeners['splash:progress'] = []
}
/**
* 读取当前进度 (供组件初始化)
* 同时返回已解析的 label, 避免组件再维护一份 STAGE_LABEL
*/
getProgress() {
const def = STAGES[this._stage]
return {
stage: this._stage,
percent: this._percent,
label: def ? def.label : this._stage,
}
}
/**
* 暴露 STAGES 表 (供 splash 组件直接消费, 避免重复维护一份)
*/
getStageDef(stageId) {
return STAGES[stageId]
}
/**
* 立即刷新挂起的进度更新
* - reportProgress 默认 16ms throttle (1 帧), 避免连续触发 splash 组件多次 re-render
* - 业务侧若要"立即"刷新 (例如 splash 即将隐藏), 可调本方法
* - 不存在 pending 时 noop
*/
flushProgress() {
if (this._pendingProgress) {
const p = this._pendingProgress
this._pendingProgress = null
if (this._flushTimer) {
clearTimeout(this._flushTimer)
this._flushTimer = null
}
this._emit('splash:progress', p)
}
}
}
const splashState = new SplashState();