Files
lucky_shop/scripts/verify-p2-keys.js
ZF sun 2d7d7f731c fix(remote lang tree): 修复命名空间被基本类型值覆盖的bug
修复remote-only模式下语言包归一化的历史bug:当页面leaf顶层存在与命名空间同名的字符串键时,会覆盖整个namespace导致子key失效。通过对齐normalizePageLangPack的两层合并逻辑来修复:
1. 先将页面语言包按路径段转为命名空间结构再合并
2. 额外平铺leaf的基本类型键到根节点以兼容短key调用
2026-07-04 09:25:01 +08:00

330 lines
9.3 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 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);
}