Prechádzať zdrojové kódy

test(统一支付): 菜单契约校验脚本

alphaH 1 týždeň pred
rodič
commit
1918d715da
1 zmenil súbory, kde vykonal 374 pridanie a 0 odobranie
  1. 374 0
      frontend/scripts/verify-unified-menu.mjs

+ 374 - 0
frontend/scripts/verify-unified-menu.mjs

@@ -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;