| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- #!/usr/bin/env node
- /**
- * 统一支付平台菜单契约校验(Task 22)。
- * 用法:cd frontend && node scripts/verify-unified-menu.mjs
- *
- * 静态检查:只读 unified-menu.sql 与三个 SFC 的 defineOptions,不连库、不执行 Vue、不联网。
- * 每条断言打印一行 ok/fail;末行 SUMMARY ok=<n> fail=<n>;有 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;
|