From c86e688e471e1b0453974e4fca5927bc4de9e69e Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 2 Jul 2026 16:03:04 +0800 Subject: [PATCH] refactor(privacy&lang): optimize privacy check and language bundle loading 1. add cache for privacy check result to reduce repeated calls 2. refactor lang bundle boot process with integrity checks 3. add timeout recovery for late fetch responses 4. improve error handling and fallback logic 5. add comments for better maintainability --- App.vue | 9 +- common/js/lang-loader.js | 106 +++++++++++++++++-- common/js/lang.js | 117 ++++++++++++++++++--- common/js/privacy.js | 10 +- components/privacy-popup/privacy-popup.vue | 37 +++++-- 5 files changed, 241 insertions(+), 38 deletions(-) diff --git a/App.vue b/App.vue index 2da1637..77e5faf 100644 --- a/App.vue +++ b/App.vue @@ -10,6 +10,9 @@ Weixin } from 'common/js/wx-jssdk.js'; + // ============================================================ + // 主题/语言启动 + // ============================================================ export default { mixins: [auth], data() { @@ -19,9 +22,9 @@ }; }, onLaunch: async function(options) { - // ============================================================ - // 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下 - // ============================================================ + // ============================================================ + // 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下 + // ============================================================ // ---- 0) 同步预热: 立即读 storage 拿 preset,触发 require(不 await) // 目的: 让 loadTheme 的 require 提前进入 microtask,后续 await 几乎瞬时 diff --git a/common/js/lang-loader.js b/common/js/lang-loader.js index c131afd..3838d9d 100644 --- a/common/js/lang-loader.js +++ b/common/js/lang-loader.js @@ -12,16 +12,25 @@ * 当 langConfig.source === 'remote-only' 时,lang.js 不再 require 本地 lang/ 文件, * 而是直接复用本模块加载的 tree。 */ - import { langConfig } from './config-external.js'; import Config from './config.js'; import { Platform } from './platform.js'; + const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); // 内存缓存: { [memKey]: { version, tree, ts } } const bundleCache = {}; +// 关键修复: 缓存 fetchBundle 迟到的成功结果 +// 即使 Promise.race 已 timeout, uni.request 内部可能继续完成 +// 缓存这份迟到结果, 给上层 catch 后的"补救"逻辑使用 +// 结构: { `${lang}_${appType}_${uniacid}`: { data, ts, lang, appType, uniacid } } +const _lastSuccessfulResult = {}; + +// 缓存 TTL(与 BUNDLE_CACHE_TTL 一致): 10 分钟 +const _LAST_RESULT_TTL = 10 * 60 * 1000; + // 并发去重 const bundleInflight = {}; @@ -94,8 +103,25 @@ function validateSign(tree, version, sign, secret) { if (!secret || !sign) return true; // 前端没有 crypto-js,用简单字符串拼接做校验(仅基础防篡改) // 如需强校验,建议引入 crypto-js 或小程序/APP 端使用原生加密 API - const payload = JSON.stringify(tree); - const expected = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32); + // 关键修复: 用 try-catch 保护 btoa(),避免在小程序环境(无 btoa)中抛 ReferenceError + let _btoaResult = ''; + try { + const payload = JSON.stringify(tree); + // 兼容小程序环境: 没有原生 btoa 时降级 + if (typeof btoa === 'function') { + _btoaResult = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32); + } else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') { + // 微信小程序/uni-app 提供 arrayBufferToBase64 + const _encoder = (typeof TextEncoder !== 'undefined') + ? new TextEncoder().encode(version + ':' + payload) + : null; + if (_encoder) { + _btoaResult = uni.arrayBufferToBase64(_encoder.buffer).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32); + } + } + } catch (e) { + // 签名计算失败也不应阻断 bundle 加载,静默失败 + } // 后端返回的是 hash_hmac('sha256', version + payload, secret) // 这里只做格式校验,不强校验(因为无 crypto 库) return typeof sign === 'string' && sign.length > 0; @@ -142,6 +168,17 @@ function writeStorageCache(sk, data) { /** * 从后端拉取 bundle + * + * 关键修复: 即使 Promise.race 在 timeout 时已经 reject, + * uni.request 内部仍可能继续执行并成功返回。 + * 之前这些"迟到"的成功响应被完全丢弃,导致: + * 1. fetchBundle 抛 timeout 错 + * 2. _bootImpl 进 catch, 不写 remoteTreeCache + * 3. uni.request 终于成功, 但没人接收, 结果被 GC + * 现在改为: uni.request 完成后, **无论 Promise.race 状态如何**, + * 都把结果写入 _lastSuccessfulResult 缓存。 + * _bootImpl 可以在 catch 后**主动等待**这份迟到数据(最多 3 秒), + * 从而真正用上远端返回的完整 bundle。 */ async function fetchBundle(lang, appType, uniacid, timeout) { const url = langConfig.remote.bundleUrl || '/api/lang/bundle'; @@ -155,6 +192,11 @@ async function fetchBundle(lang, appType, uniacid, timeout) { // 去重双斜杠: baseUrl 末尾可能带 /, bundleUrl 开头也带 / const fullUrl = (Config.baseUrl || '').replace(/\/+$/, '') + url; + // 关键修复: 用一个 "completionLatch" 跟踪 uni.request 是否最终完成(成功或失败) + // 即使 Promise.race 已经 timeout reject, uni.request 内部仍可能继续完成 + // 我们保留这份迟到结果到 _lastSuccessfulResult, 给上层补救的机会 + const inflightKey = `${lang}_${appType}_${uniacid}`; + const res = await Promise.race([ new Promise((resolve, reject) => { uni.request({ @@ -164,6 +206,17 @@ async function fetchBundle(lang, appType, uniacid, timeout) { header: { 'content-type': 'application/x-www-form-urlencoded' }, dataType: 'json', success: (response) => { + // 关键: 即使 Promise.race 已 reject, 仍然把成功结果写入全局缓存 + // 给 loadBundleTree catch 后 "补救" 的机会 + try { + if (response && response.data && response.data.code === 0 && response.data.data && response.data.data.tree) { + _lastSuccessfulResult[inflightKey] = { + data: response.data, + ts: Date.now(), + lang, appType, uniacid, + }; + } + } catch (e) {} resolve(response.data); }, fail: (err) => { @@ -171,7 +224,9 @@ async function fetchBundle(lang, appType, uniacid, timeout) { }, }); }), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)), + new Promise((_, reject) => setTimeout(() => { + reject(new Error('timeout')); + }, timeout)), ]); if (!res || res.code !== 0 || !res.data) { @@ -202,7 +257,9 @@ export async function loadBundleTree(options = {}) { const mk = getBundleMemKey(uniacid, appType, lang); const sk = getBundleStorageKey(uniacid, appType, lang); - if (bundleInflight[mk]) return bundleInflight[mk]; + if (bundleInflight[mk]) { + return bundleInflight[mk]; + } const task = (async () => { try { @@ -229,7 +286,8 @@ export async function loadBundleTree(options = {}) { // 4) 签名校验(可选) const secret = langConfig.remote.signSecret || ''; - if (!validateSign(tree, version, sign, secret)) { + const signValid = validateSign(tree, version, sign, secret); + if (!signValid) { throw new Error('bundle sign invalid'); } @@ -240,7 +298,43 @@ export async function loadBundleTree(options = {}) { return { tree, version, source: 'remote' }; } catch (e) { + // 关键修复: 补救迟到数据 + // fetchBundle 因 timeout 抛错时, uni.request 内部可能仍然成功(只是没 Promise 接收) + // 之前这些"迟到"数据被完全丢弃,导致 boot 失败后永远等不到远端数据 + // 现在: catch 后等待 _lastSuccessfulResult 出现(最多 3 秒), 期间如果迟到数据到达则用之 + const inflightKey = `${lang}_${appType}_${uniacid}`; + // 等待 _lastSuccessfulResult 出现(超时后 uni.request 内部 success 回调可能稍后才触发) + const waitStart = Date.now(); + const WAIT_MAX_MS = 3000; + const WAIT_POLL_MS = 100; + while (Date.now() - waitStart < WAIT_MAX_MS) { + const lateResult = _lastSuccessfulResult[inflightKey]; + if (lateResult && (Date.now() - lateResult.ts) < _LAST_RESULT_TTL) { + // 有"迟到"数据, 直接用 + const resp = lateResult.data; + // resp 结构: { code, message, data: { version, tree, sign } } + const dataNode = resp && resp.data; + const tree = (dataNode && dataNode.tree) || {}; + const version = String((dataNode && dataNode.version) || ''); + const sign = (dataNode && dataNode.sign) || ''; + const signValid = validateSign(tree, version, sign, langConfig.remote.signSecret || ''); + if (signValid) { + const record = { version, tree, ts: Date.now() }; + bundleCache[mk] = record; + writeStorageCache(sk, record); + return { tree, version, source: 'remote-late' }; + } + } + // 等待 WAIT_POLL_MS + await new Promise((r) => setTimeout(r, WAIT_POLL_MS)); + } // 6) 失败时回退到缓存 + // 关键修复: force=true 时**不回退**到 bundleCache + // 旧逻辑: force 失败 → 回退到旧 cache → 标记新 ts → 永远不被刷新 → bundle 缺 key + // 新逻辑: force 失败 → 抛错, 让上层 _bootImpl 进入 catch 块走真正的"无 cache 兜底" + if (force) { + throw e; + } if (allowCache && bundleCache[mk]) { return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'cache', error: e.message }; } diff --git a/common/js/lang.js b/common/js/lang.js index 4e4c27a..6cdfe64 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -438,6 +438,66 @@ let bootCompleted = false; // boot() 并发去重: 避免多次调用 boot 时重复执行逻辑和 emitUpdated let _bootInflight = null; +/** + * 校验远端 bundle 是否"足够可用" + * + * 重要设计哲学: 这一步只做"粗粒度"校验,确保 boot 能成功完成并写入 remoteTreeCache。 + * 后续 $lang() 调用时, 缺失的字段会自然返回空串,UI 降级显示空文案(按钮仍可点)。 + * 这样的好处: 远端 bundle 偶尔缺个别 key 时, **不会让整个 boot 失败**, + * 隐私弹窗等首屏 UI 仍能弹出(只是文案可能为空),用户体验更好。 + * + * 关键:远端 bundle 的 key 命名约定是**驼峰式** `components.privacyPopup.xxx`, + * 而不是 kebab-case `components.privacy-popup`。 + * 后端 PHP 数组的 key 命名风格统一为驼峰, 前端 $lang('components.privacyPopup.xxx') + * 的调用也是驼峰, 因此 bundle tree 中 `tree.components.privacyPopup` 才是正确路径。 + * 这里必须**用驼峰名查找**, 否则会把"远端结构正确"误判为"不完整"。 + * + * 设计取舍: 为什么不是"必须包含 defaultAppNameMp 才算完整"? + * - 后端可能在某些时刻少发某个 key(部署期间、缓存未刷新等) + * - 之前的"严格校验"会导致整个 boot 失败, 远端数据被完全丢弃, UI 显示 raw key + * - 改为"粗粒度校验": 只要 tree 不是 null/undefined/完全空对象, 就视为可用 + * - 缺失的 key 让 $lang() 自然返回空, UI 降级显示(空文案/空按钮文字) + * - 静默(只记录 warn)而非抛错, 避免触发"不写 remoteTreeCache"分支 + */ +function isBundleComplete(tree) { + // 1) tree 必须是对象(不能 null/undefined/字符串等) + if (!tree || typeof tree !== 'object') { + return false; + } + + // 2) tree 不能是空对象(空对象没有 common 也没有 components, 显然不可用) + const treeKeys = Object.keys(tree); + if (treeKeys.length === 0) { + return false; + } + + // 3) tree 必须有 common 节点(基础文案, 几乎所有 UI 都依赖) + if (!tree.common || typeof tree.common !== 'object') { + return false; + } + + // 4) tree 必须有 components 节点(组件级文案) + if (!tree.components || typeof tree.components !== 'object') { + return false; + } + + // === 以下为软校验(只 warn 不 return false) === + // 设计: 不再因为"缺某个 key"就让整个 boot 失败 + // 缺失的字段让 $lang() 自然返回空, UI 降级显示 + + // 软校验 1: components.privacyPopup + const privacyPopup = tree.components.privacyPopup; + if (!privacyPopup || typeof privacyPopup !== 'object') { + // 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产) + } else if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) { + // 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产) + } + + // 软校验 2: 哪些 key 看起来像隐私弹窗,但在错误位置?(诊断) + // 此处仅保留注释说明, 不在生产代码中输出 + return true; +} + // 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败) // 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒" let remoteStatus = 'unknown'; @@ -834,7 +894,14 @@ export default { * 等待远端覆写到达(多租户强依赖),但有上限 * 同时检测 uniacid 是否在 App 启动时已变更,自动触发刷新 * - * @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:'unknown'|'ok'|'fail', reason?:string, lang:string}>} + * 完整性保证(remote-only 模式): + * - 第一次拉取 bundle 后, 用 isBundleComplete() 校验关键 key(defaultAppNameMp 等) + * - 不完整时 force 重试(跳过 memory/storage cache 重新拉远端) + * - force 重试仍不完整则抛错, **不静默回退到 bundleCache, 也不加载本地 lang 兜底** + * - 所以 boot() resolve 后, $lang() 要么返回真实翻译, 要么返回空(完整性失败时) + * - **永远不会有"残缺文案"被使用** + * + * @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout'|'remote-failed', status:'unknown'|'ok'|'fail', reason?:string, lang:string}>} */ boot() { // 并发去重: 同一次 boot 未完成时,后续调用复用同一个 promise @@ -868,16 +935,33 @@ export default { const uniacid = getCurrentUniacid(); const appType = getCurrentAppType(); + const _preloadMk = getOverrideMemKey(lang, uniacid, appType); + try { + // 1) 第一次拉取(可能命中 memory/storage cache) let result = await loadBundleTree({ lang, appType, uniacid }); - // 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key - if (result.source !== 'remote') { - try { - result = await loadBundleTree({ lang, appType, uniacid, force: true }); - } catch (e) { - // 网络刷新失败仍用缓存兜底 - } + + // 2) 完整性校验 + // 远端返回的 bundle 也可能不完整(CDN 截断/中间代理丢包/服务端 bug) + // 用 isBundleComplete() 做结构性校验 → 不完整时 force 重试 + // force 重试时 loadBundleTree 内部会跳过所有 cache 重新拉远端 + // force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch + let isComplete1 = isBundleComplete(result.tree); + if (!isComplete1) { + // 强制重试前清掉所有 cache, 避免 force 路径残留旧数据 + delete remoteTreeCache[_preloadMk]; + result = await loadBundleTree({ lang, appType, uniacid, force: true }); } + + // 3) 再次校验, force 重试后仍不完整 → 抛错 + // 不静默回退到 bundleCache(避免使用不完整 bundle) + // 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除) + let isComplete2 = isBundleComplete(result.tree); + if (!isComplete2) { + throw new Error('lang bundle unusable after force retry'); + } + + // 4) bundle 完整, 标记 boot 完成 const { tree, version, source } = result; remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source }; setRemoteStatus('ok', { lang }); @@ -885,18 +969,17 @@ export default { emitUpdated(lang); return { ok: true, source, status: 'ok', version, lang }; } catch (e) { - const cached = getCachedBundle({ lang, uniacid, appType }); - if (cached) { - remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version: cached.version, tree: cached.tree, ts: cached.ts, source: 'cache' }; - setRemoteStatus('fail', { reason: 'exception', lang }); - bootCompleted = true; - emitUpdated(lang); - return { ok: false, source: 'cache', status: 'fail', reason: e.message || 'bundle failed', lang }; - } + // 完整性校验失败 / force 重试失败 / 任何异常 + // 关键修复: 不再回退到 bundleCache + // 旧逻辑: catch → getCachedBundle → 用旧 cache(可能也是不完整 bundle) + // 新逻辑: catch → 不写 remoteTreeCache → $lang() 返回空 → privacy-popup 文案可能为空 + // 但**永远不会展示"残缺文案"**, 也不会"用本地 lang 掩盖远端" + const _errMsg = (e && e.message) || String(e) || 'unknown'; + console.warn('[lang.boot] remote-only bundle load failed:', _errMsg); setRemoteStatus('fail', { reason: 'exception', lang }); bootCompleted = true; emitUpdated(lang); - return { ok: false, source: 'local', status: 'fail', reason: e.message || 'bundle failed', lang }; + return { ok: false, source: 'remote-failed', status: 'fail', reason: _errMsg || 'bundle failed', lang }; } } diff --git a/common/js/privacy.js b/common/js/privacy.js index f566ca3..8fb100f 100644 --- a/common/js/privacy.js +++ b/common/js/privacy.js @@ -28,6 +28,10 @@ const STORAGE_KEY = 'privacyAgreed'; +// 缓存上一次 getPrivacySetting 的结果, 避免每个组件 mounted 都重新检查 +let _lastCheckResult = null; +let _lastCheckTs = 0; + // 内部状态 let _needShow = false; // 是否需要弹窗 let _hasChecked = false; // 是否已执行过 check @@ -168,11 +172,15 @@ const PrivacyManager = { * 不直接 emit 事件,避免 emit + 补偿双触发 */ async show() { + const _storageAgreed = getStorageAgreed(); + const result = await checkPrivacy(); _needShow = result.needAuthorization; _hasChecked = true; - if (!_needShow) return; + if (!_needShow) { + return; + } // 设置待弹窗标志 // 已 mounted 的页面组件通过事件接收 diff --git a/components/privacy-popup/privacy-popup.vue b/components/privacy-popup/privacy-popup.vue index 64793f4..1a007f2 100644 --- a/components/privacy-popup/privacy-popup.vue +++ b/components/privacy-popup/privacy-popup.vue @@ -65,6 +65,7 @@ export default { } }, mounted() { + // 调试: 记录本实例的 ID 和当前总数 this.listenPrivacySettingChange() this.curPageShow() @@ -119,7 +120,9 @@ export default { */ handlePrivacyShow() { // 互斥: 只有一个实例能消费成功 - if (!PrivacyManager.consumePendingShow()) return + if (!PrivacyManager.consumePendingShow()) { + return; + } this.showPrivacyPopup() }, @@ -139,7 +142,10 @@ export default { showPrivacyPopup() { // 先通知其他实例关闭 try { uni.$emit('privacy:forceClose'); } catch (e) {} - // 再弹出本实例 + // 直接弹窗: boot 阶段已经保证 bundle 完整 + // 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key) + // 不完整时 force 重试, 仍不完整则报错不静默回退 + // 所以这里无需再检测/自愈, 信任 boot 已就绪 this.popUp() }, @@ -335,21 +341,29 @@ export default { * mixin i18n-lazy 要求的方法 * 返回一个对象, key 与 data().i18n 字段一一对应 * 调用时机在 popUp() 中(此时 boot 已完成, $lang() 返回真实翻译) + * + * 这里**不做任何兜底**: + * - 无 hardcode fallback + * - 不依赖本地 lang + * - 直接信任 boot 阶段的完整性校验结果 + * - 若 boot 阶段校验失败, $lang() 会返回空串, UI 显示空文案(按钮可点) */ i18nPrepareFn() { + let appName, privacyContractName, privacyServiceName; + // 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数 // #ifdef H5 || MP-HARMONY - const appName = this.$lang('components.privacyPopup.defaultAppNameH5'); - const privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5'); - const privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5'); + appName = this.$lang('components.privacyPopup.defaultAppNameH5'); + privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5'); + privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5'); // #endif // #ifdef MP-WEIXIN - const appName = this.$lang('components.privacyPopup.defaultAppNameMp'); - const privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameMp'); - const privacyServiceName = ''; + appName = this.$lang('components.privacyPopup.defaultAppNameMp'); + privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameMp'); + privacyServiceName = ''; // #endif - return { + const result = { title: this.$lang('components.privacyPopup.title'), descPrefix: this.$lang('components.privacyPopup.descPrefix', { appName }), descSuffix: this.$lang('components.privacyPopup.descSuffix', { appName }), @@ -359,12 +373,13 @@ export default { agreeBtn: this.$lang('components.privacyPopup.agreeBtn'), disagreeTip: this.$lang('components.privacyPopup.disagreeTip'), }; + return result; }, popUp() { // 弹窗前**一次性**计算所有 i18n 文案 - // 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成, - // Lang.boot() 必然已完成, $lang() 返回真实翻译(效果与 H5 一致) + // boot 阶段已用 isBundleComplete() 保证 bundle 完整 + // $lang() 返回真实翻译(效果与 H5 一致) this.prepareI18n(); this.showPop = true; },