#!/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", }, ]; // 四个按钮的 (permission -> 父页面 route_path) 必须恰好是这四对。 const BUTTONS = [ { permission: "module_payment:unified:order:detail", pagePath: "/payment/unified/order" }, { permission: "module_payment:unified:order:sync", pagePath: "/payment/unified/order" }, { permission: "module_payment:unified:order:refund", pagePath: "/payment/unified/order" }, { 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]; if (ctx.pageRows.length === 0) { problems.push("no page rows in SQL"); } 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}'` ); } } 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; } if (!/VALUES\s*\(\s*1\s*\)\s*,\s*\(\s*2\s*\)/.test(ctx.authSegment)) { problems.push( "authorization segment lacks role pair (VALUES (1), (2)) — role 1 and role 2 required" ); } 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*'money'\s*,\s*'payment-unified'\s*,\s*'\/payment\/unified'\s*,/; if (!rowShape.test(segment)) { problems.push( "directory row route_name='payment-unified' icon='money' 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 parent_id is not NULL (expected gen_random_uuid(), NULL, '0')"); } return problems; } function checkIdempotentGuards(ctx) { const problems = [...ctx.sharedProblems]; const menuInserts = countMatches(ctx.sql, /INSERT\s+INTO\s+sys_menu/gi); const roleInserts = countMatches(ctx.sql, /INSERT\s+INTO\s+sys_role_menus/gi); const guards = countMatches(ctx.sql, /WHERE\s+NOT\s+EXISTS/gi); if (menuInserts + roleInserts > guards) { problems.push( `INSERT INTO sys_menu=${menuInserts} + INSERT INTO sys_role_menus=${roleInserts} > 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 expectedByPermission = new Map( BUTTONS.map((button) => [button.permission, button.pagePath]) ); for (const row of ctx.buttonRows) { const expectedPath = expectedByPermission.get(row.permission); if (expectedPath === undefined) { problems.push(`unexpected button permission '${row.permission}' -> ${row.pagePath}`); } else if (row.pagePath !== expectedPath) { problems.push(`button ${row.permission} -> ${row.pagePath}, expected -> ${expectedPath}`); } } for (const button of BUTTONS) { if (!ctx.buttonRows.some((row) => row.permission === button.permission)) { problems.push(`button ${button.permission} missing (expected -> ${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='money' / parent_id NULL", checkDirectoryRow], ["每个 INSERT 都带 WHERE NOT EXISTS", checkIdempotentGuards], ["gen_random_uuid() 覆盖三段 sys_menu INSERT", checkUuidDefaults], ["按钮权限绑定到正确父页面", 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;