/** * 远端语言包 bundle 加载器 * * 职责: * 1. 从后端 /api/lang/bundle 拉取完整语言树 * 2. 做本地 storage 缓存 + 内存缓存 * 3. 支持签名校验(可选) * 4. 支持 diff 增量更新(预留) * 5. 对外暴露 loadBundleTree / getCachedBundle / clearBundleCache * * 与 lang.js 的关系: * 当 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 = {}; // 并发去重 const bundleInflight = {}; /** * 三维隔离 key: __ */ function getBundleMemKey(uniacid, appType, lang) { return `${uniacid || 0}_${appType || '*'}_${lang}`; } /** * storage key: 与 memKey 维度一致 */ function getBundleStorageKey(uniacid, appType, lang) { return `${langConfig.remote.storagePrefix || 'LANG_BUNDLE_'}${getBundleMemKey(uniacid, appType, lang)}`; } function getCurrentLocale() { try { return uni.getStorageSync('lang') || langConfig.defaultLang || 'zh-cn'; } catch (e) { return langConfig.defaultLang || 'zh-cn'; } } function getCurrentUniacid() { try { const u = uni.getStorageSync('uniacid'); return u ? parseInt(u, 10) || 0 : 0; } catch (e) { return 0; } } function getCurrentAppType() { try { return Platform.getAppType() || '*'; } catch (e) { return '*'; } } /** * 深度合并两个对象,source 覆盖 target */ function deepMerge(target, source) { if (target === null || typeof target !== 'object') return source; if (source === null || typeof source !== 'object') return source; if (Array.isArray(target) || Array.isArray(source)) return source; for (const key in source) { if (!Object.prototype.hasOwnProperty.call(source, key)) continue; if (FORBIDDEN_KEYS.has(key)) continue; const srcVal = source[key]; const tgtVal = target[key]; if ( srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) && tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal) ) { target[key] = deepMerge(tgtVal, srcVal); } else { target[key] = srcVal; } } return target; } /** * 简易签名校验 * 后端: hash_hmac('sha256', version + json_encode(tree), secret) * 前端: 同样算法 */ 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); // 后端返回的是 hash_hmac('sha256', version + payload, secret) // 这里只做格式校验,不强校验(因为无 crypto 库) return typeof sign === 'string' && sign.length > 0; } // 缓存 TTL(ms): 与后端 Cache::set 600s 对齐,过期后后台刷新 const BUNDLE_CACHE_TTL = (langConfig.remote.cacheTtl || 600) * 1000; /** * 从 storage 读取缓存(检查 TTL 过期) * @returns {Object|null} 未过期返回 {tree,version,ts}, 过期返回 null */ function readStorageCache(sk) { try { const cached = uni.getStorageSync(sk); if (cached && cached.tree && typeof cached.tree === 'object') { // TTL 检查: 超过 cacheTtl 视为过期 if (cached.ts && (Date.now() - cached.ts < BUNDLE_CACHE_TTL)) { return cached; } } } catch (e) {} return null; } /** * 检查内存缓存是否过期 */ function isMemoryCacheExpired(record) { if (!record || !record.ts) return true; return (Date.now() - record.ts) >= BUNDLE_CACHE_TTL; } /** * 写入 storage 缓存 */ function writeStorageCache(sk, data) { try { uni.setStorageSync(sk, data); } catch (e) { // storage 写满时静默失败 } } /** * 从后端拉取 bundle */ async function fetchBundle(lang, appType, uniacid, timeout) { const url = langConfig.remote.bundleUrl || '/api/lang/bundle'; const data = { lang }; if (appType && appType !== '*') data.app_type = appType; // uniacid 必须传给后端,后端按 (uniacid × app_type) 二维查询租户专属覆写 if (uniacid !== undefined && uniacid !== null) data.uniacid = uniacid; // 直接用 uni.request 包装 Promise,不依赖 http.sendRequest // (sendRequest 的异步分支不返回 Promise,只有 async:false 分支才返回) // 去重双斜杠: baseUrl 末尾可能带 /, bundleUrl 开头也带 / const fullUrl = (Config.baseUrl || '').replace(/\/+$/, '') + url; const res = await Promise.race([ new Promise((resolve, reject) => { uni.request({ url: fullUrl, method: 'POST', data: data, header: { 'content-type': 'application/x-www-form-urlencoded' }, dataType: 'json', success: (response) => { resolve(response.data); }, fail: (err) => { reject(new Error(err.errMsg || 'bundle request failed')); }, }); }), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)), ]); if (!res || res.code !== 0 || !res.data) { throw new Error(res && res.message ? res.message : 'bundle fetch failed'); } return res.data; } /** * 加载 bundle tree * * @param {Object} options * @param {string} lang 语言 * @param {string} [appType] 平台类型 * @param {number} [uniacid] 租户 id * @param {boolean} [allowCache=true] 是否允许使用缓存 * @param {boolean} [force=false] 是否强制刷新 * @returns {Promise<{tree:Object, version:string, source:string}>} */ export async function loadBundleTree(options = {}) { const lang = options.lang || getCurrentLocale(); const appType = options.appType || getCurrentAppType(); const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid(); const allowCache = options.allowCache !== false; const force = options.force === true; const mk = getBundleMemKey(uniacid, appType, lang); const sk = getBundleStorageKey(uniacid, appType, lang); if (bundleInflight[mk]) return bundleInflight[mk]; const task = (async () => { try { // 1) 内存缓存未过期直接返回 if (!force && allowCache && bundleCache[mk] && !isMemoryCacheExpired(bundleCache[mk])) { return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' }; } // 2) 读取 storage 缓存(已含 TTL 检查) let cached = null; if (!force && allowCache) { cached = readStorageCache(sk); if (cached && !bundleCache[mk]) { bundleCache[mk] = cached; } } // 3) 远端拉取 const timeout = langConfig.remote.requestTimeout || 4000; const payload = await fetchBundle(lang, appType, uniacid, timeout); const tree = payload.tree || {}; const version = String(payload.version || ''); const sign = payload.sign || ''; // 4) 签名校验(可选) const secret = langConfig.remote.signSecret || ''; if (!validateSign(tree, version, sign, secret)) { throw new Error('bundle sign invalid'); } // 5) 更新缓存 const record = { version, tree, ts: Date.now() }; bundleCache[mk] = record; writeStorageCache(sk, record); return { tree, version, source: 'remote' }; } catch (e) { // 6) 失败时回退到缓存 if (allowCache && bundleCache[mk]) { return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'cache', error: e.message }; } throw e; } finally { delete bundleInflight[mk]; } })(); bundleInflight[mk] = task; return task; } /** * 获取已缓存的 bundle(不触发网络请求) */ export function getCachedBundle(options = {}) { const lang = options.lang || getCurrentLocale(); const appType = options.appType || getCurrentAppType(); const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid(); const mk = getBundleMemKey(uniacid, appType, lang); const sk = getBundleStorageKey(uniacid, appType, lang); if (bundleCache[mk]) return bundleCache[mk]; const cached = readStorageCache(sk); if (cached) { bundleCache[mk] = cached; return cached; } return null; } /** * 清除 bundle 缓存 */ export function clearBundleCache(options = {}) { const lang = options.lang || getCurrentLocale(); const appType = options.appType || getCurrentAppType(); const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid(); const mk = getBundleMemKey(uniacid, appType, lang); const sk = getBundleStorageKey(uniacid, appType, lang); delete bundleCache[mk]; try { uni.removeStorageSync(sk); } catch (e) {} } /** * 检查是否有缓存可用(用于启动时快速判断) */ export function hasBundleCache(options = {}) { return getCachedBundle(options) !== null; } /** * 合并本地语言包与远端 bundle tree(混合模式用) */ export function mergeLocalWithBundle(localTree, bundleTree) { if (!localTree || typeof localTree !== 'object') return bundleTree || {}; if (!bundleTree || typeof bundleTree !== 'object') return localTree || {}; return deepMerge(deepMerge({}, localTree), bundleTree); }