verify-unified-menu.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. #!/usr/bin/env node
  2. /**
  3. * 统一支付平台菜单契约校验(Task 22)。
  4. * 用法:cd frontend && node scripts/verify-unified-menu.mjs
  5. *
  6. * 静态检查:只读 unified-menu.sql 与三个 SFC 的 defineOptions,不连库、不执行 Vue、不联网。
  7. * 每条断言打印一行 ok/fail;末行 SUMMARY ok=<n> fail=<n>;有 fail 时退出码非 0。
  8. */
  9. import { existsSync, readFileSync } from "node:fs";
  10. import path from "node:path";
  11. import { fileURLToPath } from "node:url";
  12. const here = path.dirname(fileURLToPath(import.meta.url));
  13. const frontendRoot = path.resolve(here, "..");
  14. const repoRoot = path.resolve(frontendRoot, "..");
  15. const SQL_PATH = path.join(repoRoot, "java", "src", "main", "resources", "db", "unified-menu.sql");
  16. const VIEWS_ROOT = path.join(frontendRoot, "src", "views");
  17. const PERMISSION_PREFIX = "module_payment:unified:";
  18. // 契约常量:SQL 的页面行必须与这三组值逐字一致(route_name 还要与 SFC 一致)。
  19. const PAGES = [
  20. {
  21. sfc: "module_payment/unified/merchant/index.vue",
  22. routeName: "payment-unified-merchant",
  23. routePath: "/payment/unified/merchant",
  24. componentPath: "module_payment/unified/merchant/index",
  25. permission: "module_payment:unified:merchant:list",
  26. },
  27. {
  28. sfc: "module_payment/unified/order/index.vue",
  29. routeName: "payment-unified-order",
  30. routePath: "/payment/unified/order",
  31. componentPath: "module_payment/unified/order/index",
  32. permission: "module_payment:unified:order:list",
  33. },
  34. {
  35. sfc: "module_payment/unified/cashier/index.vue",
  36. routeName: "payment-unified-cashier",
  37. routePath: "/payment/unified/cashier",
  38. componentPath: "module_payment/unified/cashier/index",
  39. permission: "module_payment:unified:cashier:list",
  40. },
  41. ];
  42. // 四个按钮的 (permission -> 父页面 route_path) 必须恰好是这四对。
  43. const BUTTONS = [
  44. { permission: "module_payment:unified:order:detail", pagePath: "/payment/unified/order" },
  45. { permission: "module_payment:unified:order:sync", pagePath: "/payment/unified/order" },
  46. { permission: "module_payment:unified:order:refund", pagePath: "/payment/unified/order" },
  47. { permission: "module_payment:unified:cashier:create", pagePath: "/payment/unified/cashier" },
  48. ];
  49. const FORBIDDEN_PERMISSIONS = ["merchant:save", "merchant:delete"];
  50. function readUtf8(filePath) {
  51. try {
  52. return readFileSync(filePath, "utf8");
  53. } catch {
  54. return null;
  55. }
  56. }
  57. /**
  58. * 去掉 `--` 行注释(尊重单引号字符串,含 '' 转义)。
  59. * 本 SQL 的注释里出现 `INSERT INTO sys_menu` / `gen_random_uuid()` 等字样,
  60. * 不去注释会让计数与分段产生假读数。
  61. */
  62. function stripLineComments(sql) {
  63. let out = "";
  64. let inString = false;
  65. for (let index = 0; index < sql.length; index += 1) {
  66. const char = sql[index];
  67. if (inString) {
  68. out += char;
  69. if (char === "'") {
  70. if (sql[index + 1] === "'") {
  71. out += sql[index + 1];
  72. index += 1;
  73. } else {
  74. inString = false;
  75. }
  76. }
  77. continue;
  78. }
  79. if (char === "'") {
  80. inString = true;
  81. out += char;
  82. continue;
  83. }
  84. if (char === "-" && sql[index + 1] === "-") {
  85. while (index < sql.length && sql[index] !== "\n") {
  86. index += 1;
  87. }
  88. out += "\n";
  89. continue;
  90. }
  91. out += char;
  92. }
  93. return out;
  94. }
  95. function countMatches(text, pattern) {
  96. return [...text.matchAll(pattern)].length;
  97. }
  98. /** 从每个 INSERT INTO 起点切到「下一个 INSERT INTO」为止;没有下一个则到文末。 */
  99. function buildInsertSegments(sql, startPattern) {
  100. const starts = [...sql.matchAll(startPattern)].map((match) => match.index);
  101. const insertStarts = [...sql.matchAll(/INSERT\s+INTO/gi)].map((match) => match.index);
  102. return starts.map((start) => {
  103. const nextInsert = insertStarts.find((index) => index > start);
  104. return sql.slice(start, nextInsert === undefined ? sql.length : nextInsert);
  105. });
  106. }
  107. /** 页面行元组:('名称', 序号, 'permission', 'route_name', 'route_path', 'component_path') */
  108. function parsePageRows(segment) {
  109. const pattern =
  110. /\(\s*'([^']*)'\s*,\s*(\d+)\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g;
  111. return [...segment.matchAll(pattern)].map((match) => ({
  112. name: match[1],
  113. order: Number(match[2]),
  114. permission: match[3],
  115. routeName: match[4],
  116. routePath: match[5],
  117. componentPath: match[6],
  118. }));
  119. }
  120. /** 按钮行元组:('名称', 序号, 'permission', '父页面 route_path') */
  121. function parseButtonRows(segment) {
  122. const pattern = /\(\s*'([^']*)'\s*,\s*(\d+)\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g;
  123. return [...segment.matchAll(pattern)].map((match) => ({
  124. name: match[1],
  125. order: Number(match[2]),
  126. permission: match[3],
  127. pagePath: match[4],
  128. }));
  129. }
  130. function readSfcName(sfcRelative) {
  131. const source = readUtf8(path.join(VIEWS_ROOT, sfcRelative));
  132. if (source === null) {
  133. return null;
  134. }
  135. const match = /defineOptions\s*\(\s*\{[\s\S]*?\bname\s*:\s*["']([^"']+)["']/.exec(source);
  136. return match ? match[1] : null;
  137. }
  138. const sqlRaw = readUtf8(SQL_PATH);
  139. const sql = sqlRaw === null ? "" : stripLineComments(sqlRaw);
  140. const sysMenuSegments = buildInsertSegments(sql, /INSERT\s+INTO\s+sys_menu/gi);
  141. const authSegments = buildInsertSegments(sql, /INSERT\s+INTO\s+sys_role_menus/gi);
  142. const context = {
  143. sql,
  144. sysMenuSegments,
  145. authSegment: authSegments.length > 0 ? authSegments[0] : null,
  146. pageRows: sysMenuSegments[1] ? parsePageRows(sysMenuSegments[1]) : [],
  147. buttonRows: sysMenuSegments[2] ? parseButtonRows(sysMenuSegments[2]) : [],
  148. sfcNames: new Map(PAGES.map((page) => [page.sfc, readSfcName(page.sfc)])),
  149. sharedProblems: sqlRaw === null ? [`cannot read SQL file ${SQL_PATH}`] : [],
  150. };
  151. // ─────────────────────────── 9 条断言 ───────────────────────────
  152. function checkRouteNames(ctx) {
  153. const problems = [...ctx.sharedProblems];
  154. const expectedPaths = PAGES.map((page) => page.routePath);
  155. if (ctx.pageRows.length !== PAGES.length) {
  156. problems.push(`expected ${PAGES.length} page rows in SQL, found ${ctx.pageRows.length}`);
  157. }
  158. for (const row of ctx.pageRows) {
  159. if (!expectedPaths.includes(row.routePath)) {
  160. problems.push(`unexpected page row route_path '${row.routePath}'`);
  161. }
  162. }
  163. for (const page of PAGES) {
  164. const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath);
  165. if (!row) {
  166. problems.push(`route ${page.routePath}: no page row in SQL`);
  167. continue;
  168. }
  169. if (row.routeName !== page.routeName) {
  170. problems.push(
  171. `route ${page.routePath}: SQL route_name '${row.routeName}' != expected '${page.routeName}'`
  172. );
  173. }
  174. const sfcName = ctx.sfcNames.get(page.sfc);
  175. if (sfcName === null) {
  176. problems.push(`route ${page.routePath}: SFC ${page.sfc} has no defineOptions({ name })`);
  177. } else if (row.routeName !== sfcName) {
  178. problems.push(
  179. `route ${page.routePath}: SQL route_name '${row.routeName}' != SFC ${page.sfc} defineOptions.name '${sfcName}'`
  180. );
  181. }
  182. }
  183. return problems;
  184. }
  185. function checkComponentFiles(ctx) {
  186. const problems = [...ctx.sharedProblems];
  187. for (const page of PAGES) {
  188. const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath);
  189. if (!row) {
  190. problems.push(`route ${page.routePath}: cannot check component_path (no page row in SQL)`);
  191. continue;
  192. }
  193. if (row.componentPath !== page.componentPath) {
  194. problems.push(
  195. `route ${page.routePath}: component_path '${row.componentPath}' != expected '${page.componentPath}'`
  196. );
  197. }
  198. const filePath = path.join(VIEWS_ROOT, `${row.componentPath}.vue`);
  199. if (!existsSync(filePath)) {
  200. problems.push(
  201. `route ${page.routePath}: component_path '${row.componentPath}' -> missing ${filePath}`
  202. );
  203. }
  204. }
  205. return problems;
  206. }
  207. function checkPagePermissions(ctx) {
  208. const problems = [...ctx.sharedProblems];
  209. if (ctx.pageRows.length === 0) {
  210. problems.push("no page rows in SQL");
  211. }
  212. for (const row of ctx.pageRows) {
  213. if (!row.permission) {
  214. problems.push(`route ${row.routePath}: page row has no permission`);
  215. } else if (!row.permission.startsWith(PERMISSION_PREFIX)) {
  216. problems.push(
  217. `route ${row.routePath}: permission '${row.permission}' must start with '${PERMISSION_PREFIX}'`
  218. );
  219. }
  220. }
  221. return problems;
  222. }
  223. function checkPermissionStrings(ctx) {
  224. const problems = [...ctx.sharedProblems];
  225. const permissions = [
  226. ...PAGES.map((page) => page.permission),
  227. ...BUTTONS.map((b) => b.permission),
  228. ];
  229. for (const permission of permissions) {
  230. if (!ctx.sql.includes(permission)) {
  231. problems.push(`permission '${permission}' missing from SQL`);
  232. }
  233. }
  234. for (const forbidden of FORBIDDEN_PERMISSIONS) {
  235. if (ctx.sql.includes(forbidden)) {
  236. problems.push(`forbidden permission fragment '${forbidden}' present in SQL`);
  237. }
  238. }
  239. return problems;
  240. }
  241. function checkRoles(ctx) {
  242. const problems = [...ctx.sharedProblems];
  243. if (ctx.authSegment === null) {
  244. problems.push("no INSERT INTO sys_role_menus authorization segment");
  245. return problems;
  246. }
  247. if (!/VALUES\s*\(\s*1\s*\)\s*,\s*\(\s*2\s*\)/.test(ctx.authSegment)) {
  248. problems.push(
  249. "authorization segment lacks role pair (VALUES (1), (2)) — role 1 and role 2 required"
  250. );
  251. }
  252. return problems;
  253. }
  254. function checkDirectoryRow(ctx) {
  255. const problems = [...ctx.sharedProblems];
  256. const segment = ctx.sysMenuSegments[0];
  257. if (!segment) {
  258. problems.push("directory row: no first INSERT INTO sys_menu segment");
  259. return problems;
  260. }
  261. const rowShape = /NULL\s*,\s*'money'\s*,\s*'payment-unified'\s*,\s*'\/payment\/unified'\s*,/;
  262. if (!rowShape.test(segment)) {
  263. problems.push(
  264. "directory row route_name='payment-unified' icon='money' route_path='/payment/unified' not found in expected column shape"
  265. );
  266. }
  267. if (!/gen_random_uuid\s*\(\s*\)\s*,\s*NULL\s*,\s*'0'/.test(segment)) {
  268. problems.push("directory row parent_id is not NULL (expected gen_random_uuid(), NULL, '0')");
  269. }
  270. return problems;
  271. }
  272. function checkIdempotentGuards(ctx) {
  273. const problems = [...ctx.sharedProblems];
  274. const menuInserts = countMatches(ctx.sql, /INSERT\s+INTO\s+sys_menu/gi);
  275. const roleInserts = countMatches(ctx.sql, /INSERT\s+INTO\s+sys_role_menus/gi);
  276. const guards = countMatches(ctx.sql, /WHERE\s+NOT\s+EXISTS/gi);
  277. if (menuInserts + roleInserts > guards) {
  278. problems.push(
  279. `INSERT INTO sys_menu=${menuInserts} + INSERT INTO sys_role_menus=${roleInserts} > WHERE NOT EXISTS=${guards}`
  280. );
  281. }
  282. return problems;
  283. }
  284. function checkUuidDefaults(ctx) {
  285. const problems = [...ctx.sharedProblems];
  286. const uuidCount = countMatches(ctx.sql, /gen_random_uuid\s*\(\s*\)/gi);
  287. if (uuidCount < 3) {
  288. problems.push(`gen_random_uuid() appears ${uuidCount} times, expected >= 3`);
  289. }
  290. if (ctx.sysMenuSegments.length !== 3) {
  291. problems.push(`expected 3 INSERT INTO sys_menu segments, found ${ctx.sysMenuSegments.length}`);
  292. }
  293. ctx.sysMenuSegments.forEach((segment, index) => {
  294. if (!/gen_random_uuid\s*\(\s*\)/i.test(segment)) {
  295. problems.push(`INSERT INTO sys_menu segment ${index + 1} sets no gen_random_uuid()`);
  296. }
  297. });
  298. return problems;
  299. }
  300. function checkButtonParents(ctx) {
  301. const problems = [...ctx.sharedProblems];
  302. if (ctx.buttonRows.length !== BUTTONS.length) {
  303. problems.push(`expected ${BUTTONS.length} button rows, found ${ctx.buttonRows.length}`);
  304. }
  305. const expectedByPermission = new Map(
  306. BUTTONS.map((button) => [button.permission, button.pagePath])
  307. );
  308. for (const row of ctx.buttonRows) {
  309. const expectedPath = expectedByPermission.get(row.permission);
  310. if (expectedPath === undefined) {
  311. problems.push(`unexpected button permission '${row.permission}' -> ${row.pagePath}`);
  312. } else if (row.pagePath !== expectedPath) {
  313. problems.push(`button ${row.permission} -> ${row.pagePath}, expected -> ${expectedPath}`);
  314. }
  315. }
  316. for (const button of BUTTONS) {
  317. if (!ctx.buttonRows.some((row) => row.permission === button.permission)) {
  318. problems.push(`button ${button.permission} missing (expected -> ${button.pagePath})`);
  319. }
  320. }
  321. return problems;
  322. }
  323. const CHECKS = [
  324. ["route_name 与 SFC defineOptions.name 逐字一致", checkRouteNames],
  325. ["component_path 指向的 SFC 文件存在", checkComponentFiles],
  326. ["页面行 permission 存在且前缀正确", checkPagePermissions],
  327. ["7 个权限串齐全且无 merchant:save/merchant:delete", checkPermissionStrings],
  328. ["授权段包含 role 1 与 role 2", checkRoles],
  329. ["目录行 route_name='payment-unified' / icon='money' / parent_id NULL", checkDirectoryRow],
  330. ["每个 INSERT 都带 WHERE NOT EXISTS", checkIdempotentGuards],
  331. ["gen_random_uuid() 覆盖三段 sys_menu INSERT", checkUuidDefaults],
  332. ["按钮权限绑定到正确父页面", checkButtonParents],
  333. ];
  334. let ok = 0;
  335. let fail = 0;
  336. CHECKS.forEach(([description, run], index) => {
  337. const number = index + 1;
  338. const problems = run(context);
  339. if (problems.length === 0) {
  340. ok += 1;
  341. console.log(`ok ${number} ${description}`);
  342. } else {
  343. fail += 1;
  344. console.log(`fail ${number} ${description}: ${problems.join("; ")}`);
  345. }
  346. });
  347. console.log(`SUMMARY ok=${ok} fail=${fail}`);
  348. process.exitCode = fail === 0 ? 0 : 1;