修复remote-only模式下语言包归一化的历史bug:当页面leaf顶层存在与命名空间同名的字符串键时,会覆盖整个namespace导致子key失效。通过对齐normalizePageLangPack的两层合并逻辑来修复: 1. 先将页面语言包按路径段转为命名空间结构再合并 2. 额外平铺leaf的基本类型键到根节点以兼容短key调用
330 lines
9.3 KiB
JavaScript
330 lines
9.3 KiB
JavaScript
/**
|
||
* P2 动态 key 验证脚本
|
||
*
|
||
* 作用:枚举审计报告 P2 中运行时拼接的 $lang() key 的所有已知取值,
|
||
* 模拟 common/js/lang.js 的合并/解析逻辑,检查这些 key 是否都能在
|
||
* zh-cn / en-us 语言包中解析。
|
||
*
|
||
* 用法:node scripts/verify-p2-keys.js
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
|
||
function loadLangFile(filePath) {
|
||
const fullPath = path.join(ROOT, filePath);
|
||
if (!fs.existsSync(fullPath)) {
|
||
console.error('Missing language pack:', filePath);
|
||
return {};
|
||
}
|
||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||
const match = content.match(/export\s+const\s+lang\s*=\s*([\s\S]*?);?\s*$/);
|
||
if (!match) return {};
|
||
try {
|
||
return eval('(' + match[1] + ')');
|
||
} catch (e) {
|
||
console.error('Failed to parse', filePath, e.message);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function getValue(obj, keys) {
|
||
let current = obj;
|
||
for (const key of keys) {
|
||
if (current && typeof current === 'object' && !Array.isArray(current) && key in current) {
|
||
current = current[key];
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
return current;
|
||
}
|
||
|
||
function toCamelCaseName(name) {
|
||
return String(name || '').replace(/[-_]([a-z])/g, function(_, letter) {
|
||
return letter.toUpperCase();
|
||
});
|
||
}
|
||
|
||
function deepCloneLang(obj) {
|
||
if (obj === null || typeof obj !== 'object') return obj;
|
||
if (Array.isArray(obj)) {
|
||
const result = new Array(obj.length);
|
||
for (let i = 0; i < obj.length; i++) {
|
||
result[i] = deepCloneLang(obj[i]);
|
||
}
|
||
return result;
|
||
}
|
||
const result = {};
|
||
for (const k in obj) {
|
||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||
result[k] = deepCloneLang(obj[k]);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||
|
||
function deepMergeLang(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] = deepMergeLang(tgtVal, srcVal);
|
||
} else {
|
||
target[key] = deepCloneLang(srcVal);
|
||
}
|
||
}
|
||
return target;
|
||
}
|
||
|
||
function normalizeSharedLangPack(packPath, langData) {
|
||
if (!langData || typeof langData !== 'object') return {};
|
||
|
||
const segments = String(packPath || '').split('/');
|
||
const group = segments[0];
|
||
const fileName = segments[segments.length - 1];
|
||
if (group !== 'components' && group !== 'components-diy') return langData;
|
||
|
||
if (langData[group]) return langData;
|
||
|
||
const componentName = toCamelCaseName(fileName);
|
||
const keys = Object.keys(langData);
|
||
|
||
if (keys.length === 1 && langData[keys[0]] && typeof langData[keys[0]] === 'object') {
|
||
return {
|
||
[group]: langData
|
||
};
|
||
}
|
||
|
||
return {
|
||
[group]: {
|
||
[componentName]: langData
|
||
}
|
||
};
|
||
}
|
||
|
||
function normalizePageLangPack(packPath, langData) {
|
||
if (!packPath || !langData || typeof langData !== 'object') return langData || {};
|
||
|
||
const segments = String(packPath).split('/').filter(Boolean);
|
||
if (!segments.length) return langData;
|
||
|
||
let leaf = langData;
|
||
let hasLeaf = true;
|
||
for (const segment of segments) {
|
||
const key = toCamelCaseName(segment);
|
||
if (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && leaf[key] !== undefined) {
|
||
leaf = leaf[key];
|
||
} else {
|
||
hasLeaf = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const lastSegment = toCamelCaseName(segments[segments.length - 1]);
|
||
const currentPageLeaf =
|
||
hasLeaf && leaf && typeof leaf === 'object' && !Array.isArray(leaf)
|
||
? leaf
|
||
: (langData[lastSegment] && typeof langData[lastSegment] === 'object' && !Array.isArray(langData[lastSegment]) ? langData[lastSegment] : langData);
|
||
let result = deepCloneLang(langData);
|
||
if (currentPageLeaf !== langData) {
|
||
result = deepMergeLang(deepCloneLang(currentPageLeaf), result);
|
||
}
|
||
if (!result[lastSegment]) {
|
||
result[lastSegment] = deepCloneLang(currentPageLeaf);
|
||
}
|
||
|
||
let namespaced = {};
|
||
{
|
||
let cursor = namespaced;
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const segment = toCamelCaseName(segments[i]);
|
||
cursor[segment] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
|
||
cursor = cursor[segment];
|
||
}
|
||
}
|
||
|
||
// 同步建立 snake_case 镜像:让 camelCase 与 snake_case 路径都能解析到同一份 leaf
|
||
// 与 bundle 服务器侧 emit 形式对齐(bundle 用 snake_case 段名,如 point.order_list.statusAll)
|
||
const nested = [];
|
||
{
|
||
let cursor = namespaced;
|
||
nested.push(cursor);
|
||
for (let i = 0; i < segments.length - 1; i++) {
|
||
cursor = cursor[toCamelCaseName(segments[i])];
|
||
nested.push(cursor);
|
||
}
|
||
}
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const raw = segments[i];
|
||
const camel = toCamelCaseName(raw);
|
||
const snake = raw.replace(/([A-Z])/g, '_$1').replace(/-/g, '_').toLowerCase();
|
||
if (snake !== camel) {
|
||
const isLeaf = i === segments.length - 1;
|
||
nested[i][snake] = isLeaf ? deepCloneLang(currentPageLeaf) : {};
|
||
}
|
||
}
|
||
|
||
return deepMergeLang(result, namespaced);
|
||
}
|
||
|
||
function buildContext(locale, sharedPacks, pagePackPath) {
|
||
let context = deepCloneLang(loadLangFile(`lang/${locale}/common.js`));
|
||
|
||
for (const sharedPath of sharedPacks) {
|
||
const pack = loadLangFile(`lang/${locale}/${sharedPath}.js`);
|
||
const normalized = normalizeSharedLangPack(sharedPath, pack);
|
||
context = deepMergeLang(context, normalized);
|
||
}
|
||
|
||
if (pagePackPath) {
|
||
const pack = loadLangFile(`lang/${locale}/${pagePackPath}.js`);
|
||
const normalized = normalizePageLangPack(pagePackPath, pack);
|
||
context = deepMergeLang(context, normalized);
|
||
}
|
||
|
||
return context;
|
||
}
|
||
|
||
function checkKey(context, key) {
|
||
const value = getValue(context, key.split('.'));
|
||
return typeof value === 'string' ? value : undefined;
|
||
}
|
||
|
||
// ===================== 动态 key 定义 =====================
|
||
|
||
const P2_CASES = [
|
||
{
|
||
file: 'components/payment/payment.vue',
|
||
locale: 'zh-cn',
|
||
shared: ['components/payment'],
|
||
keys: ['components.payment.alipay', 'components.payment.wechatpay', 'components.payment.balancePay']
|
||
},
|
||
{
|
||
file: 'components/payment/payment.vue',
|
||
locale: 'en-us',
|
||
shared: ['components/payment'],
|
||
keys: ['components.payment.alipay', 'components.payment.wechatpay', 'components.payment.balancePay']
|
||
},
|
||
{
|
||
file: 'pages_order/list.vue',
|
||
locale: 'zh-cn',
|
||
page: 'order/list',
|
||
keys: ['order.list.status.all', 'order.list.status.waitpay', 'order.list.status.waitsend', 'order.list.status.waitconfirm', 'order.list.status.waitrate']
|
||
},
|
||
{
|
||
file: 'pages_order/list.vue',
|
||
locale: 'en-us',
|
||
page: 'order/list',
|
||
keys: ['order.list.status.all', 'order.list.status.waitpay', 'order.list.status.waitsend', 'order.list.status.waitconfirm', 'order.list.status.waitrate']
|
||
},
|
||
{
|
||
file: 'pages_promotion/fenxiao/order.vue',
|
||
locale: 'zh-cn',
|
||
page: 'fenxiao/order',
|
||
keys: ['all', 'pendingSettlement', 'settled', 'refunded']
|
||
},
|
||
{
|
||
file: 'pages_promotion/fenxiao/order.vue',
|
||
locale: 'en-us',
|
||
page: 'fenxiao/order',
|
||
keys: ['all', 'pendingSettlement', 'settled', 'refunded']
|
||
},
|
||
{
|
||
file: 'pages_promotion/fenxiao/team.vue',
|
||
locale: 'zh-cn',
|
||
page: 'fenxiao/team',
|
||
keys: ['levelOne', 'levelTwo']
|
||
},
|
||
{
|
||
file: 'pages_promotion/fenxiao/team.vue',
|
||
locale: 'en-us',
|
||
page: 'fenxiao/team',
|
||
keys: ['levelOne', 'levelTwo']
|
||
},
|
||
{
|
||
file: 'pages_promotion/point/order_list.vue',
|
||
locale: 'zh-cn',
|
||
page: 'point/order_list',
|
||
keys: ['point.order_list.statusAll', 'point.order_list.statusPendingPayment', 'point.order_list.statusCompleted']
|
||
},
|
||
{
|
||
file: 'pages_promotion/point/order_list.vue',
|
||
locale: 'en-us',
|
||
page: 'point/order_list',
|
||
keys: ['point.order_list.statusAll', 'point.order_list.statusPendingPayment', 'point.order_list.statusCompleted']
|
||
},
|
||
{
|
||
file: 'pages_tool/goods/evaluate.vue',
|
||
locale: 'zh-cn',
|
||
page: 'goods/evaluate',
|
||
keys: ['tabAll', 'tabPositive', 'tabNeutral', 'tabNegative']
|
||
},
|
||
{
|
||
file: 'pages_tool/goods/evaluate.vue',
|
||
locale: 'en-us',
|
||
page: 'goods/evaluate',
|
||
keys: ['tabAll', 'tabPositive', 'tabNeutral', 'tabNegative']
|
||
},
|
||
{
|
||
file: 'pages_tool/pay/cashier.vue',
|
||
locale: 'zh-cn',
|
||
page: 'pay/cashier',
|
||
keys: ['pay.cashier.alipay', 'pay.cashier.wechatpay']
|
||
},
|
||
{
|
||
file: 'pages_tool/pay/cashier.vue',
|
||
locale: 'en-us',
|
||
page: 'pay/cashier',
|
||
keys: ['pay.cashier.alipay', 'pay.cashier.wechatpay']
|
||
}
|
||
];
|
||
|
||
// ===================== 执行校验 =====================
|
||
|
||
let allOk = true;
|
||
let currentFile = '';
|
||
|
||
for (const c of P2_CASES) {
|
||
if (currentFile !== c.file) {
|
||
console.log(`\nChecking ${c.file}:`);
|
||
currentFile = c.file;
|
||
}
|
||
|
||
const context = buildContext(c.locale, c.shared || [], c.page);
|
||
for (const key of c.keys) {
|
||
const value = checkKey(context, key);
|
||
if (value === undefined) {
|
||
console.log(` ❌ [${c.locale}] MISSING: ${key}`);
|
||
allOk = false;
|
||
} else {
|
||
console.log(` ✅ [${c.locale}] ${key} => ${value}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('\n⚠️ components-diy/diy-tab.vue 的 title 为运行时 DIY 数据,无法静态枚举,建议通过 DIY 数据规范/白名单保证。');
|
||
|
||
if (allOk) {
|
||
console.log('\n✅ 所有可枚举的 P2 动态 key 均可解析');
|
||
process.exit(0);
|
||
} else {
|
||
console.log('\n❌ 部分 P2 动态 key 缺失');
|
||
process.exit(1);
|
||
}
|