|
|
@@ -0,0 +1,374 @@
|
|
|
+#!/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",
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+// 四个按钮的 (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;
|