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; },