verify-unified-menu.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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. // 四个按钮的 (name -> permission -> 父页面 route_path) 三元组必须恰好是这四组。
  43. const BUTTONS = [
  44. {
  45. name: "订单详情",
  46. permission: "module_payment:unified:order:detail",
  47. pagePath: "/payment/unified/order",
  48. },
  49. {
  50. name: "同步上游",
  51. permission: "module_payment:unified:order:sync",
  52. pagePath: "/payment/unified/order",
  53. },
  54. {
  55. name: "发起退款",
  56. permission: "module_payment:unified:order:refund",
  57. pagePath: "/payment/unified/order",
  58. },
  59. {
  60. name: "发起收款",
  61. permission: "module_payment:unified:cashier:create",
  62. pagePath: "/payment/unified/cashier",
  63. },
  64. ];
  65. const FORBIDDEN_PERMISSIONS = ["merchant:save", "merchant:delete"];
  66. function readUtf8(filePath) {
  67. try {
  68. return readFileSync(filePath, "utf8");
  69. } catch {
  70. return null;
  71. }
  72. }
  73. /**
  74. * 去掉 `--` 行注释(尊重单引号字符串,含 '' 转义)。
  75. * 本 SQL 的注释里出现 `INSERT INTO sys_menu` / `gen_random_uuid()` 等字样,
  76. * 不去注释会让计数与分段产生假读数。
  77. */
  78. function stripLineComments(sql) {
  79. let out = "";
  80. let inString = false;
  81. for (let index = 0; index < sql.length; index += 1) {
  82. const char = sql[index];
  83. if (inString) {
  84. out += char;
  85. if (char === "'") {
  86. if (sql[index + 1] === "'") {
  87. out += sql[index + 1];
  88. index += 1;
  89. } else {
  90. inString = false;
  91. }
  92. }
  93. continue;
  94. }
  95. if (char === "'") {
  96. inString = true;
  97. out += char;
  98. continue;
  99. }
  100. if (char === "-" && sql[index + 1] === "-") {
  101. while (index < sql.length && sql[index] !== "\n") {
  102. index += 1;
  103. }
  104. out += "\n";
  105. continue;
  106. }
  107. out += char;
  108. }
  109. return out;
  110. }
  111. function countMatches(text, pattern) {
  112. return [...text.matchAll(pattern)].length;
  113. }
  114. /** 从每个 INSERT INTO 起点切到「下一个 INSERT INTO」为止;没有下一个则到文末。 */
  115. function buildInsertSegments(sql, startPattern) {
  116. const starts = [...sql.matchAll(startPattern)].map((match) => match.index);
  117. const insertStarts = [...sql.matchAll(/INSERT\s+INTO/gi)].map((match) => match.index);
  118. return starts.map((start) => {
  119. const nextInsert = insertStarts.find((index) => index > start);
  120. return sql.slice(start, nextInsert === undefined ? sql.length : nextInsert);
  121. });
  122. }
  123. /** 页面行元组:('名称', 序号, 'permission', 'route_name', 'route_path', 'component_path') */
  124. function parsePageRows(segment) {
  125. const pattern =
  126. /\(\s*'([^']*)'\s*,\s*(\d+)\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g;
  127. return [...segment.matchAll(pattern)].map((match) => ({
  128. name: match[1],
  129. order: Number(match[2]),
  130. permission: match[3],
  131. routeName: match[4],
  132. routePath: match[5],
  133. componentPath: match[6],
  134. }));
  135. }
  136. /** 按钮行元组:('名称', 序号, 'permission', '父页面 route_path') */
  137. function parseButtonRows(segment) {
  138. const pattern = /\(\s*'([^']*)'\s*,\s*(\d+)\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g;
  139. return [...segment.matchAll(pattern)].map((match) => ({
  140. name: match[1],
  141. order: Number(match[2]),
  142. permission: match[3],
  143. pagePath: match[4],
  144. }));
  145. }
  146. function readSfcName(sfcRelative) {
  147. const source = readUtf8(path.join(VIEWS_ROOT, sfcRelative));
  148. if (source === null) {
  149. return null;
  150. }
  151. const match = /defineOptions\s*\(\s*\{[\s\S]*?\bname\s*:\s*["']([^"']+)["']/.exec(source);
  152. return match ? match[1] : null;
  153. }
  154. const sqlRaw = readUtf8(SQL_PATH);
  155. const sql = sqlRaw === null ? "" : stripLineComments(sqlRaw);
  156. const sysMenuSegments = buildInsertSegments(sql, /INSERT\s+INTO\s+sys_menu/gi);
  157. const authSegments = buildInsertSegments(sql, /INSERT\s+INTO\s+sys_role_menus/gi);
  158. const context = {
  159. sql,
  160. sysMenuSegments,
  161. authSegment: authSegments.length > 0 ? authSegments[0] : null,
  162. pageRows: sysMenuSegments[1] ? parsePageRows(sysMenuSegments[1]) : [],
  163. buttonRows: sysMenuSegments[2] ? parseButtonRows(sysMenuSegments[2]) : [],
  164. sfcNames: new Map(PAGES.map((page) => [page.sfc, readSfcName(page.sfc)])),
  165. sharedProblems: sqlRaw === null ? [`cannot read SQL file ${SQL_PATH}`] : [],
  166. };
  167. // ─────────────────────────── 9 条断言 ───────────────────────────
  168. function checkRouteNames(ctx) {
  169. const problems = [...ctx.sharedProblems];
  170. const expectedPaths = PAGES.map((page) => page.routePath);
  171. if (ctx.pageRows.length !== PAGES.length) {
  172. problems.push(`expected ${PAGES.length} page rows in SQL, found ${ctx.pageRows.length}`);
  173. }
  174. for (const row of ctx.pageRows) {
  175. if (!expectedPaths.includes(row.routePath)) {
  176. problems.push(`unexpected page row route_path '${row.routePath}'`);
  177. }
  178. }
  179. for (const page of PAGES) {
  180. const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath);
  181. if (!row) {
  182. problems.push(`route ${page.routePath}: no page row in SQL`);
  183. continue;
  184. }
  185. if (row.routeName !== page.routeName) {
  186. problems.push(
  187. `route ${page.routePath}: SQL route_name '${row.routeName}' != expected '${page.routeName}'`
  188. );
  189. }
  190. const sfcName = ctx.sfcNames.get(page.sfc);
  191. if (sfcName === null) {
  192. problems.push(`route ${page.routePath}: SFC ${page.sfc} has no defineOptions({ name })`);
  193. } else if (row.routeName !== sfcName) {
  194. problems.push(
  195. `route ${page.routePath}: SQL route_name '${row.routeName}' != SFC ${page.sfc} defineOptions.name '${sfcName}'`
  196. );
  197. }
  198. }
  199. return problems;
  200. }
  201. function checkComponentFiles(ctx) {
  202. const problems = [...ctx.sharedProblems];
  203. for (const page of PAGES) {
  204. const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath);
  205. if (!row) {
  206. problems.push(`route ${page.routePath}: cannot check component_path (no page row in SQL)`);
  207. continue;
  208. }
  209. if (row.componentPath !== page.componentPath) {
  210. problems.push(
  211. `route ${page.routePath}: component_path '${row.componentPath}' != expected '${page.componentPath}'`
  212. );
  213. }
  214. const filePath = path.join(VIEWS_ROOT, `${row.componentPath}.vue`);
  215. if (!existsSync(filePath)) {
  216. problems.push(
  217. `route ${page.routePath}: component_path '${row.componentPath}' -> missing ${filePath}`
  218. );
  219. }
  220. }
  221. return problems;
  222. }
  223. function checkPagePermissions(ctx) {
  224. const problems = [...ctx.sharedProblems];
  225. for (const row of ctx.pageRows) {
  226. if (!row.permission) {
  227. problems.push(`route ${row.routePath}: page row has no permission`);
  228. } else if (!row.permission.startsWith(PERMISSION_PREFIX)) {
  229. problems.push(
  230. `route ${row.routePath}: permission '${row.permission}' must start with '${PERMISSION_PREFIX}'`
  231. );
  232. }
  233. }
  234. for (const page of PAGES) {
  235. const row = ctx.pageRows.find((candidate) => candidate.routePath === page.routePath);
  236. if (!row) {
  237. problems.push(
  238. `route ${page.routePath}: no page row in SQL (expected permission '${page.permission}')`
  239. );
  240. continue;
  241. }
  242. if (row.permission !== page.permission) {
  243. problems.push(
  244. `route ${page.routePath}: permission '${row.permission}' != expected '${page.permission}'`
  245. );
  246. }
  247. }
  248. return problems;
  249. }
  250. function checkPermissionStrings(ctx) {
  251. const problems = [...ctx.sharedProblems];
  252. const permissions = [
  253. ...PAGES.map((page) => page.permission),
  254. ...BUTTONS.map((b) => b.permission),
  255. ];
  256. for (const permission of permissions) {
  257. if (!ctx.sql.includes(permission)) {
  258. problems.push(`permission '${permission}' missing from SQL`);
  259. }
  260. }
  261. for (const forbidden of FORBIDDEN_PERMISSIONS) {
  262. if (ctx.sql.includes(forbidden)) {
  263. problems.push(`forbidden permission fragment '${forbidden}' present in SQL`);
  264. }
  265. }
  266. return problems;
  267. }
  268. function checkRoles(ctx) {
  269. const problems = [...ctx.sharedProblems];
  270. if (ctx.authSegment === null) {
  271. problems.push("no INSERT INTO sys_role_menus authorization segment");
  272. return problems;
  273. }
  274. // 只对角色数字取集合(顺序无关);仍假设角色列表是 `(VALUES (n), (n), ...)` 形状。
  275. const valuesMatch = /VALUES\s*((?:\(\s*\d+\s*\)\s*,?\s*)+)/i.exec(ctx.authSegment);
  276. const roleIds = valuesMatch
  277. ? [...valuesMatch[1].matchAll(/\(\s*(\d+)\s*\)/g)].map((match) => Number(match[1]))
  278. : [];
  279. const roleSet = new Set(roleIds);
  280. if (!roleSet.has(1) || !roleSet.has(2)) {
  281. problems.push(
  282. `authorization role VALUES [${roleIds.join(", ")}] must contain role 1 and role 2`
  283. );
  284. }
  285. return problems;
  286. }
  287. function checkDirectoryRow(ctx) {
  288. const problems = [...ctx.sharedProblems];
  289. const segment = ctx.sysMenuSegments[0];
  290. if (!segment) {
  291. problems.push("directory row: no first INSERT INTO sys_menu segment");
  292. return problems;
  293. }
  294. const rowShape = /NULL\s*,\s*'el-icon-Wallet'\s*,\s*'payment-unified'\s*,\s*'\/payment\/unified'\s*,/;
  295. if (!rowShape.test(segment)) {
  296. problems.push(
  297. "directory row route_name='payment-unified' icon='el-icon-Wallet' route_path='/payment/unified' not found in expected column shape"
  298. );
  299. }
  300. if (!/gen_random_uuid\s*\(\s*\)\s*,\s*NULL\s*,\s*'0'/.test(segment)) {
  301. problems.push(
  302. "directory row route_path '/payment/unified': parent_id is not NULL (expected gen_random_uuid(), NULL, '0')"
  303. );
  304. }
  305. return problems;
  306. }
  307. function checkIdempotentGuards(ctx) {
  308. const problems = [...ctx.sharedProblems];
  309. const starts = [...ctx.sql.matchAll(/INSERT\s+INTO\s+(\w+)/gi)].map((match) => ({
  310. index: match.index,
  311. table: match[1],
  312. }));
  313. if (starts.length === 0 && ctx.sharedProblems.length === 0) {
  314. problems.push(`no INSERT INTO statements found in ${SQL_PATH}`);
  315. return problems;
  316. }
  317. // 逐段统计:每段(切到下一个 INSERT INTO 为止)必须自带 WHERE NOT EXISTS;
  318. // 这比「全局条数 ≤」更严,且失败时能点名到具体是哪一段。
  319. const seenByTable = new Map();
  320. starts.forEach((start, index) => {
  321. const nextStart = index + 1 < starts.length ? starts[index + 1].index : ctx.sql.length;
  322. const segment = ctx.sql.slice(start.index, nextStart);
  323. const guards = countMatches(segment, /WHERE\s+NOT\s+EXISTS/gi);
  324. const ordinal = (seenByTable.get(start.table) ?? 0) + 1;
  325. seenByTable.set(start.table, ordinal);
  326. if (guards === 0) {
  327. problems.push(
  328. `INSERT INTO ${start.table} #${ordinal} (segment ${index + 1}): INSERT=1, WHERE NOT EXISTS=${guards}`
  329. );
  330. }
  331. });
  332. return problems;
  333. }
  334. function checkUuidDefaults(ctx) {
  335. const problems = [...ctx.sharedProblems];
  336. const uuidCount = countMatches(ctx.sql, /gen_random_uuid\s*\(\s*\)/gi);
  337. if (uuidCount < 3) {
  338. problems.push(`gen_random_uuid() appears ${uuidCount} times, expected >= 3`);
  339. }
  340. if (ctx.sysMenuSegments.length < 3) {
  341. problems.push(
  342. `expected >= 3 INSERT INTO sys_menu segments, found ${ctx.sysMenuSegments.length}`
  343. );
  344. }
  345. ctx.sysMenuSegments.forEach((segment, index) => {
  346. if (!/gen_random_uuid\s*\(\s*\)/i.test(segment)) {
  347. problems.push(`INSERT INTO sys_menu segment ${index + 1} sets no gen_random_uuid()`);
  348. }
  349. });
  350. return problems;
  351. }
  352. function checkButtonParents(ctx) {
  353. const problems = [...ctx.sharedProblems];
  354. if (ctx.buttonRows.length !== BUTTONS.length) {
  355. problems.push(`expected ${BUTTONS.length} button rows, found ${ctx.buttonRows.length}`);
  356. }
  357. const expectedByName = new Map(BUTTONS.map((button) => [button.name, button]));
  358. for (const row of ctx.buttonRows) {
  359. const expected = expectedByName.get(row.name);
  360. if (expected === undefined) {
  361. problems.push(
  362. `unexpected button name '${row.name}' (permission '${row.permission}' -> ${row.pagePath})`
  363. );
  364. continue;
  365. }
  366. if (row.permission !== expected.permission) {
  367. problems.push(
  368. `button '${row.name}': permission '${row.permission}' != expected '${expected.permission}'`
  369. );
  370. }
  371. if (row.pagePath !== expected.pagePath) {
  372. problems.push(
  373. `button '${row.name}' (${row.permission}) -> ${row.pagePath}, expected -> ${expected.pagePath}`
  374. );
  375. }
  376. }
  377. for (const button of BUTTONS) {
  378. if (!ctx.buttonRows.some((row) => row.name === button.name)) {
  379. problems.push(
  380. `button '${button.name}' missing (expected ${button.permission} -> ${button.pagePath})`
  381. );
  382. }
  383. }
  384. return problems;
  385. }
  386. const CHECKS = [
  387. ["route_name 与 SFC defineOptions.name 逐字一致", checkRouteNames],
  388. ["component_path 指向的 SFC 文件存在", checkComponentFiles],
  389. ["页面行 permission 逐页等于契约值且前缀正确", checkPagePermissions],
  390. ["7 个权限串齐全且无 merchant:save/merchant:delete", checkPermissionStrings],
  391. ["授权段包含 role 1 与 role 2", checkRoles],
  392. ["目录行 route_name='payment-unified' / icon='el-icon-Wallet' / parent_id NULL", checkDirectoryRow],
  393. ["每个 INSERT 都带 WHERE NOT EXISTS", checkIdempotentGuards],
  394. ["gen_random_uuid() 覆盖每段 sys_menu INSERT(≥3 段)", checkUuidDefaults],
  395. ["按钮 name → permission → 父页面 route_path 三元组正确", checkButtonParents],
  396. ];
  397. let ok = 0;
  398. let fail = 0;
  399. CHECKS.forEach(([description, run], index) => {
  400. const number = index + 1;
  401. const problems = run(context);
  402. if (problems.length === 0) {
  403. ok += 1;
  404. console.log(`ok ${number} ${description}`);
  405. } else {
  406. fail += 1;
  407. console.log(`fail ${number} ${description}: ${problems.join("; ")}`);
  408. }
  409. });
  410. console.log(`SUMMARY ok=${ok} fail=${fail}`);
  411. process.exitCode = fail === 0 ? 0 : 1;