/** * 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); }