Files
lucky_shop/common/js/lang-loader.js
ZF sun c86e688e47 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
2026-07-02 16:03:04 +08:00

400 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 远端语言包 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 = {};
// 关键修复: 缓存 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 = {};
/**
* 三维隔离 key: <uniacid>_<appType>_<lang>
*/
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
// 关键修复: 用 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;
}
// 缓存 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
*
* 关键修复: 即使 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';
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;
// 关键修复: 用一个 "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({
url: fullUrl,
method: 'POST',
data: data,
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) => {
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 || '';
const signValid = validateSign(tree, version, sign, secret);
if (!signValid) {
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) {
// 关键修复: 补救迟到数据
// 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 };
}
throw e;
} finally {
delete bundleInflight[mk];
}
})();
bundleInflight[mk] = task;
return task;
}
/**
* 获取已缓存的 bundle(不触发网络请求)
* 注意: 受 TTL 限制,过期缓存返回 null
*/
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);
}