#!/usr/bin/env node /** * 统一支付平台菜单契约校验(Task 22)。 * 用法:cd frontend && node scripts/verify-unified-menu.mjs * * 静态检查:只读 unified-menu.sql 与三个 SFC 的 defineOptions,不连库、不执行 Vue、不联网。 * 每条断言打印一行 ok/fail;末行 SUMMARY ok= fail=;有 fail 时退出码非 0。 */ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); const frontendRoot = path.resolve(here, ".."); const repoRoot = path.resolve(frontendRoot, ".."); const SQL_PATH = path.join(repoRoot, "java", "src", "main", "resources", "db", "unified-menu.sql"); const VIEWS_ROOT = path.join(frontendRoot, "src", "views"); const PERMISSION_PREFIX = "module_payment:unified:"; // 契约常量:SQL 的页面行必须与这三组值逐字一致(route_name 还要与 SFC 一致)。 const PAGES = [ { sfc: "module_payment/unified/merchant/index.vue", routeName: "payment-unified-merchant", routePath: "/payment/unified/merchant", componentPath: "module_payment/unified/merchant/index", permission: "module_payment:unified:merchant:list", }, { sfc: "module_payment/unified/order/index.vue", routeName: "payment-unified-order", routePath: "/payment/unified/order", componentPath: "module_payment/unified/order/index", permission: "module_payment:unified:order:list", }, { sfc: "module_payment/unified/cashier/index.vue", routeName: "payment-unified-cashier", routePath: "/payment/unified/cashier", componentPath: "module_payment/unified/cashier/index", permission: "module_payment:unified:cashier:list", }, ]; // 四个按钮的 (name -> permission -> 父页面 route_path) 三元组必须恰好是这四组。 const BUTTONS = [ { name: "订单详情", permission: "module_payment:unified:order:detail", pagePath: "/payment/unified/order", }, { name: "同步上游", permission: "module_payment:unified:order:sync", pagePath: "/payment/unified/order", }, { name: "发起退款", permission: "module_payment:unified:order:refund", pagePath: "/payment/unified/order", }, { name: "发起收款", permission: "module_payment:unified:cashier:create", pagePath: "/payment/unified/cashier", }, ]; const FORBIDDEN_PERMISSIONS = ["merchant:save", "merchant:delete"]; function readUtf8(filePath) { try { return readFileSync(filePath, "utf8"); } catch { return null; } } /** * 去掉 `--` 行注释(尊重单引号字符串,含 '' 转义)。 * 本 SQL 的注释里出现 `INSERT INTO sys_menu` / `gen_random_uuid()` 等字样, * 不去注释会让计数与分段产生假读数。 */ function stripLineComments(sql) { let out = ""; let inString = false; for (let index = 0; index < sql.length; index += 1) { const char = sql[index]; if (inString) { out += char; if (char === "'") { if (sql[index + 1] === "'") { out += sql[index + 1]; index += 1; } else { inString = false; } } continue; } if (char === "'") { inString = true; out += char; continue; } if (char === "-" && sql[index + 1] === "-") { while (index < sql.length && sql[index] !== "\n") { index += 1; } out += "\n"; continue; } out += char; } return out; } function countMatches(text, pattern) { return [...text.matchAll(pattern)].length; } /** 从每个 INSERT INTO 起点切到「下一个 INSERT INTO」为止;没有下一个则到文末。 */ function buildInsertSegments(sql, startPattern) { const starts = [...sql.matchAll(startPattern)].map((match) => match.index); const insertStarts = [...sql.matchAll(/INSERT\s+INTO/gi)].map((match) => match.index); return starts.map((start) => { const nextInsert = insertStarts.find((index) => index > start); return sql.slice(start, nextInsert === undefined ? sql.length : nextInsert); }); } /** 页面行元组:('名称', 序号, 'permission', 'route_name', 'route_path', 'component_path') */ function parsePageRows(segment) { const pattern = /\(\s*'([^']*)'\s*,\s*(\d+)\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g; return [...segment.matchAll(pattern)].map((match) => ({ name: match[1], order: Number(match[2]), permission: match[3], routeName: match[4], routePath: match[5], componentPath: match[6], })); } /** 按钮行元组:('名称', 序号, 'permission', '父页面 route_path') */ function parseButtonRows(segment) { const pattern = /\(\s*'([^']*)'\s*,\s*(\d+)\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g; return [...segment.matchAll(pattern)].map((match) => ({ name: match[1], order: Number(match[2]), permission: match[3], pagePath: match[4], })); } function readSfcName(sfcRelative) { const source = readUtf8(path.join(VIEWS_ROOT, sfcRelative)); if (source === null) { return null; } const match = /defineOptions\s*\(\s*\{[\s\S]*?\bname\s*:\s*["']([^"']+)["']/.exec(source); return match ? match[1] : null; } const sqlRaw = readUtf8(SQL_PATH); const sql = sqlRaw === null ? "" : stripLineComments(sqlRaw); const sysMenuSegments = buildInsertSegments(sql, /INSERT\s+INTO\s+sys_menu/gi); const authSegments = buildInsertSegments(sql, /INSERT\s+INTO\s+sys_role_menus/gi); const context = { sql, sysMenuSegments, authSegment: authSegments.length > 0 ? authSegments[0] : null, pageRows: sysMenuSegments[1] ? parsePageRows(sysMenuSegments[1]) : [], buttonRows: sysMenuSegments[2] ? parseButtonRows(sysMenuSegments[2]) : [], sfcNames: new Map(PAGES.map((page) => [page.sfc, readSfcName(page.sfc)])), sharedProblems: sqlRaw === null ? [`cannot read SQL file ${SQL_PATH}`] : [], }; // ─────────────────────────── 9 条断言 ─────────────────────────── function checkRouteNames(ctx) { const problems = [...ctx.sharedProblems]; const expectedPaths = PAGES.map((page) => page.routePath); if (ctx.pageRows.length !== PAGES.length) { problems.push(`expected ${PAGES.length} page rows in SQL, found ${ctx.pageRows.length}`); } for (const row of ctx.pageRows) { if (!expectedPaths.includes(row.routePath)) { problems.push(`unexpected page row route_path '${row.routePath}'`); } } for (const page of PAGES) { const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath); if (!row) { problems.push(`route ${page.routePath}: no page row in SQL`); continue; } if (row.routeName !== page.routeName) { problems.push( `route ${page.routePath}: SQL route_name '${row.routeName}' != expected '${page.routeName}'` ); } const sfcName = ctx.sfcNames.get(page.sfc); if (sfcName === null) { problems.push(`route ${page.routePath}: SFC ${page.sfc} has no defineOptions({ name })`); } else if (row.routeName !== sfcName) { problems.push( `route ${page.routePath}: SQL route_name '${row.routeName}' != SFC ${page.sfc} defineOptions.name '${sfcName}'` ); } } return problems; } function checkComponentFiles(ctx) { const problems = [...ctx.sharedProblems]; for (const page of PAGES) { const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath); if (!row) { problems.push(`route ${page.routePath}: cannot check component_path (no page row in SQL)`); continue; } if (row.componentPath !== page.componentPath) { problems.push( `route ${page.routePath}: component_path '${row.componentPath}' != expected '${page.componentPath}'` ); } const filePath = path.join(VIEWS_ROOT, `${row.componentPath}.vue`); if (!existsSync(filePath)) { problems.push( `route ${page.routePath}: component_path '${row.componentPath}' -> missing ${filePath}` ); } } return problems; } function checkPagePermissions(ctx) { const problems = [...ctx.sharedProblems]; for (const row of ctx.pageRows) { if (!row.permission) { problems.push(`route ${row.routePath}: page row has no permission`); } else if (!row.permission.startsWith(PERMISSION_PREFIX)) { problems.push( `route ${row.routePath}: permission '${row.permission}' must start with '${PERMISSION_PREFIX}'` ); } } for (const page of PAGES) { const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath); if (!row) { problems.push( `route ${page.routePath}: no page row in SQL (expected permission '${page.permission}')` ); continue; } if (row.permission !== page.permission) { problems.push( `route ${page.routePath}: permission '${row.permission}' != expected '${page.permission}'` ); } } return problems; } function checkPermissionStrings(ctx) { const problems = [...ctx.sharedProblems]; const permissions = [ ...PAGES.map((page) => page.permission), ...BUTTONS.map((b) => b.permission), ]; for (const permission of permissions) { if (!ctx.sql.includes(permission)) { problems.push(`permission '${permission}' missing from SQL`); } } for (const forbidden of FORBIDDEN_PERMISSIONS) { if (ctx.sql.includes(forbidden)) { problems.push(`forbidden permission fragment '${forbidden}' present in SQL`); } } return problems; } function checkRoles(ctx) { const problems = [...ctx.sharedProblems]; if (ctx.authSegment === null) { problems.push("no INSERT INTO sys_role_menus authorization segment"); return problems; } // 只对角色数字取集合(顺序无关);仍假设角色列表是 `(VALUES (n), (n), ...)` 形状。 const valuesMatch = /VALUES\s*((?:\(\s*\d+\s*\)\s*,?\s*)+)/i.exec(ctx.authSegment); const roleIds = valuesMatch ? [...valuesMatch[1].matchAll(/\(\s*(\d+)\s*\)/g)].map((match) => Number(match[1])) : []; const roleSet = new Set(roleIds); if (!roleSet.has(1) || !roleSet.has(2)) { problems.push( `authorization role VALUES [${roleIds.join(", ")}] must contain role 1 and role 2` ); } return problems; } function checkDirectoryRow(ctx) { const problems = [...ctx.sharedProblems]; const segment = ctx.sysMenuSegments[0]; if (!segment) { problems.push("directory row: no first INSERT INTO sys_menu segment"); return problems; } const rowShape = /NULL\s*,\s*'el-icon-Wallet'\s*,\s*'payment-unified'\s*,\s*'\/payment\/unified'\s*,/; if (!rowShape.test(segment)) { problems.push( "directory row route_name='payment-unified' icon='el-icon-Wallet' route_path='/payment/unified' not found in expected column shape" ); } if (!/gen_random_uuid\s*\(\s*\)\s*,\s*NULL\s*,\s*'0'/.test(segment)) { problems.push( "directory row route_path '/payment/unified': parent_id is not NULL (expected gen_random_uuid(), NULL, '0')" ); } return problems; } function checkIdempotentGuards(ctx) { const problems = [...ctx.sharedProblems]; const starts = [...ctx.sql.matchAll(/INSERT\s+INTO\s+(\w+)/gi)].map((match) => ({ index: match.index, table: match[1], })); if (starts.length === 0 && ctx.sharedProblems.length === 0) { problems.push(`no INSERT INTO statements found in ${SQL_PATH}`); return problems; } // 逐段统计:每段(切到下一个 INSERT INTO 为止)必须自带 WHERE NOT EXISTS; // 这比「全局条数 ≤」更严,且失败时能点名到具体是哪一段。 const seenByTable = new Map(); starts.forEach((start, index) => { const nextStart = index + 1 < starts.length ? starts[index + 1].index : ctx.sql.length; const segment = ctx.sql.slice(start.index, nextStart); const guards = countMatches(segment, /WHERE\s+NOT\s+EXISTS/gi); const ordinal = (seenByTable.get(start.table) ?? 0) + 1; seenByTable.set(start.table, ordinal); if (guards === 0) { problems.push( `INSERT INTO ${start.table} #${ordinal} (segment ${index + 1}): INSERT=1, WHERE NOT EXISTS=${guards}` ); } }); return problems; } function checkUuidDefaults(ctx) { const problems = [...ctx.sharedProblems]; const uuidCount = countMatches(ctx.sql, /gen_random_uuid\s*\(\s*\)/gi); if (uuidCount < 3) { problems.push(`gen_random_uuid() appears ${uuidCount} times, expected >= 3`); } if (ctx.sysMenuSegments.length < 3) { problems.push( `expected >= 3 INSERT INTO sys_menu segments, found ${ctx.sysMenuSegments.length}` ); } ctx.sysMenuSegments.forEach((segment, index) => { if (!/gen_random_uuid\s*\(\s*\)/i.test(segment)) { problems.push(`INSERT INTO sys_menu segment ${index + 1} sets no gen_random_uuid()`); } }); return problems; } function checkButtonParents(ctx) { const problems = [...ctx.sharedProblems]; if (ctx.buttonRows.length !== BUTTONS.length) { problems.push(`expected ${BUTTONS.length} button rows, found ${ctx.buttonRows.length}`); } const expectedByName = new Map(BUTTONS.map((button) => [button.name, button])); for (const row of ctx.buttonRows) { const expected = expectedByName.get(row.name); if (expected === undefined) { problems.push( `unexpected button name '${row.name}' (permission '${row.permission}' -> ${row.pagePath})` ); continue; } if (row.permission !== expected.permission) { problems.push( `button '${row.name}': permission '${row.permission}' != expected '${expected.permission}'` ); } if (row.pagePath !== expected.pagePath) { problems.push( `button '${row.name}' (${row.permission}) -> ${row.pagePath}, expected -> ${expected.pagePath}` ); } } for (const button of BUTTONS) { if (!ctx.buttonRows.some((row) => row.name === button.name)) { problems.push( `button '${button.name}' missing (expected ${button.permission} -> ${button.pagePath})` ); } } return problems; } const CHECKS = [ ["route_name 与 SFC defineOptions.name 逐字一致", checkRouteNames], ["component_path 指向的 SFC 文件存在", checkComponentFiles], ["页面行 permission 逐页等于契约值且前缀正确", checkPagePermissions], ["7 个权限串齐全且无 merchant:save/merchant:delete", checkPermissionStrings], ["授权段包含 role 1 与 role 2", checkRoles], ["目录行 route_name='payment-unified' / icon='el-icon-Wallet' / parent_id NULL", checkDirectoryRow], ["每个 INSERT 都带 WHERE NOT EXISTS", checkIdempotentGuards], ["gen_random_uuid() 覆盖每段 sys_menu INSERT(≥3 段)", checkUuidDefaults], ["按钮 name → permission → 父页面 route_path 三元组正确", checkButtonParents], ]; let ok = 0; let fail = 0; CHECKS.forEach(([description, run], index) => { const number = index + 1; const problems = run(context); if (problems.length === 0) { ok += 1; console.log(`ok ${number} ${description}`); } else { fail += 1; console.log(`fail ${number} ${description}: ${problems.join("; ")}`); } }); console.log(`SUMMARY ok=${ok} fail=${fail}`); process.exitCode = fail === 0 ? 0 : 1;