diff --git a/App.vue b/App.vue index 9a12a6e..e9ff4c1 100644 --- a/App.vue +++ b/App.vue @@ -11,30 +11,28 @@ mixins: [auth], onLaunch: async function(options) { // 方式:支持快应用,从url中query部分获取uniacid,或useragent中获取uniacid - if(options.query.uniacid){ + const uniacidChanged = options.query.uniacid && String(uni.getStorageSync('uniacid') || '') !== String(options.query.uniacid); + if (options.query.uniacid) { uni.setStorageSync('uniacid', options.query.uniacid); } + // uniacid 显式切换后,主动通知 Lang 清旧租户缓存,避免 boot 期间读到旧值 + if (uniacidChanged) { + try { Lang.onUniacidChange(); } catch (e) {} + } // 首屏强依赖远端翻译覆写(多租户文案强隔离) // boot() 内部已有 bootTimeout 上限 + 失败静默,这里包 try 防止阻塞首屏 try { await Lang.boot(); } catch (e) { console.warn('[i18n] boot fail', e); } // 监听远端翻译更新事件(管理后台改了词条后由后端推送 / 主动 reload) + // 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh,这里 + // 只做副作用(navigation title 等),不重复 forceUpdate uni.$on && uni.$on('lang:updated', (info) => { - // 复用现有 componentRefresh 机制,触发所有引用它的组件 $forceUpdate - // 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh, - // 这里再调用一次 Lang.refresh() 是为了保证 setNavigationBarTitle - // 这类副作用也被一并触发 if (info && info.lang) { - Lang.refresh(); + Lang.refresh(true); // true = 显式推送 emitUpdated(虽然 loadOverrides 内部已发,这里保险起见) } }); - - // 远端不可用 -> 自动落到本地兜底;这里给个静默提示,业务可自行改 UI - uni.$on && uni.$on('lang:fallback', (info) => { - // console.warn('[i18n] remote unavailable, fallback to local', info); - // 不阻塞首屏,不抛错,仅记录 - }); + // lang:fallback / lang:remote-status 不在 App.vue 监听,留给业务按需订阅 uni.hideTabBar(); // #ifdef MP diff --git a/common/js/config-external.js b/common/js/config-external.js index 938c830..0472a72 100644 --- a/common/js/config-external.js +++ b/common/js/config-external.js @@ -3,8 +3,9 @@ // 语言包配置 export const langConfig = { - // 语言列表 - langList: ['zh-cn', 'en-us'], + // 语言列表 — 与后端 Lang.php::overrides() 白名单对齐 + // 增删需要后端同步改 LangOverrideModel::normalizeAppType 旁的 $allow_lang + langList: ['zh-cn', 'en-us', 'zh-tw', 'ja-jp', 'ko-kr', 'th-th', 'vi-vn', 'id-id', 'es-es', 'fr-fr', 'de-de', 'ru-ru', 'ar-sa', 'pt-pt', 'it-it', 'tr-tr'], // 默认语言 defaultLang: 'zh-cn', // 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载) @@ -22,7 +23,8 @@ export const langConfig = { bootTimeout: 2000, // 单次请求超时(ms) requestTimeout: 4000, - // 本地缓存 key 前缀: _ + // 本地缓存 key 前缀: __ + // 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致 storagePrefix: 'LANG_OVR_', // 切租户/uniacid 时强制重拉 refreshOnUniacidChange: true, diff --git a/common/js/lang.js b/common/js/lang.js index 1b4a71f..f1d2795 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -28,22 +28,41 @@ function normalizeSource(s) { } const SOURCE = normalizeSource(langConfig.source); +/** + * 远端失败原因归一(对外稳定枚举,内部细节不外泄) + * - timeout : 网络/服务端超时 + * - network : 网络层失败(断网/DNS/...) + * - http : 业务码非 0 + * - parse : 响应解析失败 + * - exception : JS 异常 + */ +function normalizeReason(s) { + const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown']; + return allow.includes(s) ? s : 'unknown'; +} + /** * 设置远端状态 + 触发监听器 + 发 uni.$emit * 注意:ok 和 fail 都是"终态",允许相互覆盖 + * detail 字段只对外透出 enum(无原始 message / error),避免内部栈外泄 */ function setRemoteStatus(s, detail) { if (remoteStatus === s) return; const prev = remoteStatus; remoteStatus = s; remoteStatusAt = Date.now(); + // 归一化 detail:reason,detail 内部不向外透 error/message + const safeDetail = { + reason: normalizeReason(detail && detail.reason), + lang: detail && detail.lang, + }; remoteStatusListeners.forEach(fn => { - try { fn(s, detail || {}); } catch (e) { /* 忽略监听器异常 */ } + try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ } }); - try { uni.$emit && uni.$emit('lang:remote-status', { from: prev, to: s, ...(detail || {}) }); } catch (e) {} + try { uni.$emit && uni.$emit('lang:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {} // 状态变成 fail 时,额外发一个"已切到本地兜底"事件,方便 UI 弹 toast if (s === 'fail') { - try { uni.$emit && uni.$emit('lang:fallback', { reason: (detail && detail.reason) || 'remote-unavailable' }); } catch (e) {} + try { uni.$emit && uni.$emit('lang:fallback', { reason: safeDetail.reason }); } catch (e) {} } } @@ -124,7 +143,7 @@ function getCurrentAppType() { /** * 持久化 key: __ - * 加入 appType 是为了:H5 端缓存不会污染 MP/APP 端 + * 三维隔离:多租户 × 多端 × 多语种,任何一维切换都拿不到旧值 */ function getOverrideStorageKey(lang) { const u = getCurrentUniacid(); @@ -133,18 +152,22 @@ function getOverrideStorageKey(lang) { } /** - * 内存 cache key: _ - * 与 storage key 一样按平台+语种二维区分 + * 内存 cache key: __ + * 必须与 storage key 三维一致 — 切租户时内存不会"巧合"命中旧值 */ function getOverrideMemKey(lang) { - return `${getCurrentAppType()}_${lang}`; + return `${getCurrentUniacid()}_${getCurrentAppType()}_${lang}`; } /** * 把"远端扁平覆写 map"按 key 路径合并到 nested 对象上 * null/undefined/非 string 视为"还原",不写入(本地值保留) * 空串 "" 是合法覆写,会写入 + * + * 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链 */ +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + function applyOverrides(merged, flatMap) { if (!flatMap) return; for (const key in flatMap) { @@ -152,7 +175,9 @@ function applyOverrides(merged, flatMap) { const val = flatMap[key]; // 类型白名单:只接 string,其它都视为非法值,跳过(不污染 merged) if (typeof val !== 'string') continue; - const parts = String(key).split('.'); + // 危险 key 过滤 + const parts = String(key).split('.').filter(p => p && !FORBIDDEN_KEYS.has(p)); + if (parts.length === 0) continue; let cur = merged; for (let i = 0; i < parts.length - 1; i++) { const p = parts[i]; @@ -450,9 +475,10 @@ export default { } if (value == undefined || (value == 'title' && field == 'title')) value = ''; // field - // 多语言调试,注释后可以关闭控制台输出 - if (field == value) { - console.warn(`警告: 字段 ${field} 在语言包 ${langPath} 中未找到对应值,使用默认值 ${field} 当前语言: ${locale}`); + // 多语言调试,只在开发模式输出 + const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production'); + if (__DEV__ && field == value) { + console.warn(`[i18n] missing key "${field}" in ${langPath || ''} (${locale})`); } return value; @@ -467,23 +493,28 @@ export default { let _page = getCurrentPages()[getCurrentPages().length - 1]; if (!_page) return; + const prevLocale = getCurrentLocale(); uni.setStorageSync("lang", value); const locale = getCurrentLocale(); - // 清空已加载的语言包缓存 - for (let key in loadedLangPacks) { - if (!key.startsWith(locale)) { - delete loadedLangPacks[key]; - } - } + // 清空已加载的"非新语种"语言包缓存 + // 用 Object.keys 防止被原型链污染(for...in 会遍历继承属性) + Object.keys(loadedLangPacks).forEach(key => { + if (!key.startsWith(locale + '_')) delete loadedLangPacks[key]; + }); + // 清空内存里的"非新语种"远端覆写 — 防止反复切语种导致内存泄漏 + Object.keys(overrideCache).forEach(mk => { + if (!mk.endsWith('_' + locale)) delete overrideCache[mk]; + }); // 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络) // 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch + // 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit if (SOURCE !== 'local') { try { await loadOverrides(value); } catch (e) { /* 静默 */ } } - this.refresh(); + this.refresh(false); // false = 不重复 emitUpdated if (url) { uni.reLaunch({ url: url }); @@ -491,14 +522,18 @@ export default { }, //刷新标题、tabbar - refresh() { + // @param {boolean} [emit=true] 是否触发 setComponentRefresh + // change() 流程里 loadOverrides 已经 emit 过,这里要传 false 防重复 + refresh(emit = true) { let _page = getCurrentPages()[getCurrentPages().length - 1]; if (_page) this.title(this.lang("title")); - // 远端 i18n 翻译更新后,推送重渲染信号; - // 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh - // 注册为全局 computed,commit 后所有引用它的组件自动重渲染 - emitUpdated(getCurrentLocale()); + if (emit) { + // 远端 i18n 翻译更新后,推送重渲染信号; + // 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh + // 注册为全局 computed,commit 后所有引用它的组件自动重渲染 + emitUpdated(getCurrentLocale()); + } //设置tabbar的文字语言 // uni.setTabBarItem({ @@ -532,20 +567,25 @@ export default { }, // 获取语言包列表 + // 优化:只读取 common.name 字段,不要全量 require 进 loadedLangPacks + // 否则后续切到不常用的语种会"恰好命中缓存",但首次访问要全量加载 list() { var list = []; - try { - //公共语言包 - for (var i = 0; i < langConfig.langList.length; i++) { - let langType = langConfig.langList[i]; - let item = loadLangPackSync(langType, 'common'); - list.push({ - name: item.common ? item.common.name : langType, - value: langType - }); + // 用一次性临时缓存避免污染 loadedLangPacks + const tmp = {}; + const getCommonName = (langType) => { + try { + if (tmp[langType]) return tmp[langType]; + const data = require(`@/lang/${langType}/common.js`).lang; + tmp[langType] = (data && data.common) ? data.common.name : langType; + return tmp[langType]; + } catch (e) { + return langType; } - } catch (e) { - console.error('获取语言包列表失败:', e); + }; + for (var i = 0; i < langConfig.langList.length; i++) { + let langType = langConfig.langList[i]; + list.push({ name: getCommonName(langType), value: langType }); } return list; },