befly 3.9.38 → 3.9.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +37 -38
  2. package/befly.config.ts +62 -40
  3. package/checks/checkApi.ts +16 -16
  4. package/checks/checkApp.ts +19 -25
  5. package/checks/checkTable.ts +42 -42
  6. package/docs/README.md +42 -35
  7. package/docs/{api.md → api/api.md} +223 -231
  8. package/docs/cipher.md +71 -69
  9. package/docs/database.md +143 -141
  10. package/docs/{examples.md → guide/examples.md} +181 -181
  11. package/docs/guide/quickstart.md +331 -0
  12. package/docs/hooks/auth.md +38 -0
  13. package/docs/hooks/cors.md +28 -0
  14. package/docs/{hook.md → hooks/hook.md} +140 -57
  15. package/docs/hooks/parser.md +19 -0
  16. package/docs/hooks/rateLimit.md +47 -0
  17. package/docs/{redis.md → infra/redis.md} +84 -93
  18. package/docs/plugins/cipher.md +61 -0
  19. package/docs/plugins/database.md +128 -0
  20. package/docs/{plugin.md → plugins/plugin.md} +83 -81
  21. package/docs/quickstart.md +26 -26
  22. package/docs/{addon.md → reference/addon.md} +46 -46
  23. package/docs/{config.md → reference/config.md} +32 -80
  24. package/docs/{logger.md → reference/logger.md} +52 -52
  25. package/docs/{sync.md → reference/sync.md} +32 -35
  26. package/docs/{table.md → reference/table.md} +1 -1
  27. package/docs/{validator.md → reference/validator.md} +57 -57
  28. package/hooks/auth.ts +8 -4
  29. package/hooks/cors.ts +13 -13
  30. package/hooks/parser.ts +37 -17
  31. package/hooks/permission.ts +26 -14
  32. package/hooks/rateLimit.ts +276 -0
  33. package/hooks/validator.ts +8 -8
  34. package/lib/asyncContext.ts +43 -0
  35. package/lib/cacheHelper.ts +212 -77
  36. package/lib/cacheKeys.ts +38 -0
  37. package/lib/cipher.ts +30 -30
  38. package/lib/connect.ts +28 -28
  39. package/lib/dbHelper.ts +183 -102
  40. package/lib/jwt.ts +16 -16
  41. package/lib/logger.ts +610 -19
  42. package/lib/redisHelper.ts +185 -44
  43. package/lib/sqlBuilder.ts +90 -91
  44. package/lib/validator.ts +59 -39
  45. package/loader/loadApis.ts +48 -44
  46. package/loader/loadHooks.ts +40 -14
  47. package/loader/loadPlugins.ts +16 -17
  48. package/main.ts +57 -47
  49. package/package.json +47 -45
  50. package/paths.ts +15 -14
  51. package/plugins/cache.ts +5 -4
  52. package/plugins/cipher.ts +3 -3
  53. package/plugins/config.ts +2 -2
  54. package/plugins/db.ts +9 -9
  55. package/plugins/jwt.ts +3 -3
  56. package/plugins/logger.ts +8 -12
  57. package/plugins/redis.ts +8 -8
  58. package/plugins/tool.ts +6 -6
  59. package/router/api.ts +85 -56
  60. package/router/static.ts +12 -12
  61. package/sync/syncAll.ts +12 -12
  62. package/sync/syncApi.ts +55 -52
  63. package/sync/syncDb/apply.ts +20 -19
  64. package/sync/syncDb/constants.ts +25 -23
  65. package/sync/syncDb/ddl.ts +35 -36
  66. package/sync/syncDb/helpers.ts +6 -9
  67. package/sync/syncDb/schema.ts +10 -9
  68. package/sync/syncDb/sqlite.ts +7 -8
  69. package/sync/syncDb/table.ts +37 -35
  70. package/sync/syncDb/tableCreate.ts +21 -20
  71. package/sync/syncDb/types.ts +23 -20
  72. package/sync/syncDb/version.ts +10 -10
  73. package/sync/syncDb.ts +43 -36
  74. package/sync/syncDev.ts +74 -65
  75. package/sync/syncMenu.ts +190 -55
  76. package/tests/api-integration-array-number.test.ts +282 -0
  77. package/tests/befly-config-env.test.ts +78 -0
  78. package/tests/cacheHelper.test.ts +135 -104
  79. package/tests/cacheKeys.test.ts +41 -0
  80. package/tests/cipher.test.ts +90 -89
  81. package/tests/dbHelper-advanced.test.ts +140 -134
  82. package/tests/dbHelper-all-array-types.test.ts +316 -0
  83. package/tests/dbHelper-array-serialization.test.ts +258 -0
  84. package/tests/dbHelper-columns.test.ts +56 -55
  85. package/tests/dbHelper-execute.test.ts +45 -44
  86. package/tests/dbHelper-joins.test.ts +124 -119
  87. package/tests/fields-redis-cache.test.ts +29 -27
  88. package/tests/fields-validate.test.ts +38 -38
  89. package/tests/getClientIp.test.ts +54 -0
  90. package/tests/integration.test.ts +69 -67
  91. package/tests/jwt.test.ts +27 -26
  92. package/tests/logger.test.ts +267 -34
  93. package/tests/rateLimit-hook.test.ts +477 -0
  94. package/tests/redisHelper.test.ts +187 -188
  95. package/tests/redisKeys.test.ts +6 -73
  96. package/tests/scanConfig.test.ts +144 -0
  97. package/tests/sqlBuilder-advanced.test.ts +217 -215
  98. package/tests/sqlBuilder.test.ts +92 -91
  99. package/tests/sync-connection.test.ts +29 -29
  100. package/tests/syncDb-apply.test.ts +97 -96
  101. package/tests/syncDb-array-number.test.ts +160 -0
  102. package/tests/syncDb-constants.test.ts +48 -47
  103. package/tests/syncDb-ddl.test.ts +99 -98
  104. package/tests/syncDb-helpers.test.ts +29 -28
  105. package/tests/syncDb-schema.test.ts +61 -60
  106. package/tests/syncDb-types.test.ts +60 -59
  107. package/tests/syncMenu-paths.test.ts +68 -0
  108. package/tests/util.test.ts +42 -41
  109. package/tests/validator-array-number.test.ts +310 -0
  110. package/tests/validator-default.test.ts +373 -0
  111. package/tests/validator.test.ts +271 -266
  112. package/tsconfig.json +4 -5
  113. package/types/api.d.ts +7 -12
  114. package/types/befly.d.ts +60 -13
  115. package/types/cache.d.ts +8 -4
  116. package/types/common.d.ts +17 -9
  117. package/types/context.d.ts +2 -2
  118. package/types/crypto.d.ts +23 -0
  119. package/types/database.d.ts +19 -19
  120. package/types/hook.d.ts +2 -2
  121. package/types/jwt.d.ts +118 -0
  122. package/types/logger.d.ts +30 -0
  123. package/types/plugin.d.ts +4 -4
  124. package/types/redis.d.ts +7 -3
  125. package/types/roleApisCache.ts +23 -0
  126. package/types/sync.d.ts +10 -10
  127. package/types/table.d.ts +50 -9
  128. package/types/validate.d.ts +69 -0
  129. package/utils/addonHelper.ts +90 -0
  130. package/utils/arrayKeysToCamel.ts +18 -0
  131. package/utils/calcPerfTime.ts +13 -0
  132. package/utils/configTypes.ts +3 -0
  133. package/utils/cors.ts +19 -0
  134. package/utils/fieldClear.ts +75 -0
  135. package/utils/genShortId.ts +12 -0
  136. package/utils/getClientIp.ts +45 -0
  137. package/utils/keysToCamel.ts +22 -0
  138. package/utils/keysToSnake.ts +22 -0
  139. package/utils/modules.ts +98 -0
  140. package/utils/pickFields.ts +19 -0
  141. package/utils/process.ts +56 -0
  142. package/utils/regex.ts +225 -0
  143. package/utils/response.ts +115 -0
  144. package/utils/route.ts +23 -0
  145. package/utils/scanConfig.ts +142 -0
  146. package/utils/scanFiles.ts +48 -0
  147. package/.prettierignore +0 -2
  148. package/.prettierrc +0 -12
  149. package/docs/1-/345/237/272/346/234/254/344/273/213/347/273/215.md +0 -35
  150. package/docs/2-/345/210/235/346/255/245/344/275/223/351/252/214.md +0 -64
  151. package/docs/3-/347/254/254/344/270/200/344/270/252/346/216/245/345/217/243.md +0 -46
  152. package/docs/4-/346/223/215/344/275/234/346/225/260/346/215/256/345/272/223.md +0 -172
  153. package/hooks/requestLogger.ts +0 -84
  154. package/types/index.ts +0 -24
  155. package/util.ts +0 -283
package/sync/syncMenu.ts CHANGED
@@ -1,9 +1,9 @@
1
- /**
1
+ /**
2
2
  * SyncMenu 命令 - 同步菜单数据到数据库
3
3
  * 说明:扫描 addon 的 views 目录和项目的 menus.json,同步菜单数据
4
4
  *
5
5
  * 流程:
6
- * 1. 扫描所有 addon 的 views 目录下的 meta.json 文件
6
+ * 1. 扫描所有 addon 的 views 目录下的 index.vue,并从 definePage({ meta }) 解析菜单元信息
7
7
  * 2. 根据目录层级构建菜单树(无层级限制)
8
8
  * 3. 读取项目的 menus.json 文件(手动配置的菜单)
9
9
  * 4. 根据菜单的 path 字段检查是否存在
@@ -13,27 +13,137 @@
13
13
  * 注:state 字段由框架自动管理(1=正常,2=禁用,0=删除)
14
14
  */
15
15
 
16
- import { existsSync } from 'node:fs';
17
- import { readdir, readFile } from 'node:fs/promises';
18
- import { join } from 'pathe';
16
+ import type { SyncMenuOptions, MenuConfig } from "../types/sync.js";
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { readdir, readFile } from "node:fs/promises";
19
20
 
20
- import { Connect } from '../lib/connect.js';
21
- import { DbHelper } from '../lib/dbHelper.js';
22
- import { RedisHelper } from '../lib/redisHelper.js';
23
- import { RedisKeys } from 'befly-shared/redisKeys';
24
- import { scanAddons, getAddonDir } from 'befly-shared/addonHelper';
25
- import { Logger } from '../lib/logger.js';
26
- import { projectDir } from '../paths.js';
27
- import { beflyConfig } from '../befly.config.js';
21
+ import { join } from "pathe";
28
22
 
29
- import type { SyncMenuOptions, MenuConfig } from '../types/index.js';
23
+ import { beflyConfig } from "../befly.config.js";
24
+ import { CacheKeys } from "../lib/cacheKeys.js";
25
+ import { Connect } from "../lib/connect.js";
26
+ import { DbHelper } from "../lib/dbHelper.js";
27
+ import { Logger } from "../lib/logger.js";
28
+ import { RedisHelper } from "../lib/redisHelper.js";
29
+ import { projectDir } from "../paths.js";
30
+ import { scanAddons, getAddonDir } from "../utils/addonHelper.js";
30
31
 
31
32
  /**
32
33
  * 清理目录名中的数字后缀
33
34
  * 如:login_1 → login, index_2 → index
34
35
  */
35
36
  function cleanDirName(name: string): string {
36
- return name.replace(/_\d+$/, '');
37
+ return name.replace(/_\d+$/, "");
38
+ }
39
+
40
+ type ViewDirMeta = {
41
+ title: string;
42
+ order?: number;
43
+ };
44
+
45
+ function normalizeMenuPath(path: string): string {
46
+ // 约束:统一 path 形态,避免隐藏菜单匹配、DB 同步出现重复
47
+ // - 必须以 / 开头
48
+ // - 折叠多个 /
49
+ // - 去掉尾随 /(根 / 除外)
50
+ let result = path;
51
+
52
+ if (!result) {
53
+ return "/";
54
+ }
55
+
56
+ if (!result.startsWith("/")) {
57
+ result = `/${result}`;
58
+ }
59
+
60
+ result = result.replace(/\/+/g, "/");
61
+
62
+ if (result.length > 1) {
63
+ result = result.replace(/\/+$/, "");
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ function normalizeMenuTree(menus: MenuConfig[]): MenuConfig[] {
70
+ // 递归规范化并按 path 去重(同 path 的 children 合并)
71
+ const map = new Map<string, MenuConfig>();
72
+
73
+ for (const menu of menus) {
74
+ const menuPath = menu.path ? normalizeMenuPath(menu.path) : "";
75
+ const cloned: MenuConfig = {
76
+ name: menu.name,
77
+ path: menuPath,
78
+ sort: menu.sort
79
+ };
80
+
81
+ if (menu.children && menu.children.length > 0) {
82
+ cloned.children = normalizeMenuTree(menu.children);
83
+ }
84
+
85
+ if (!menuPath) {
86
+ // path 缺失的菜单无法参与同步/去重,直接丢弃
87
+ continue;
88
+ }
89
+
90
+ const existing = map.get(menuPath);
91
+ if (existing) {
92
+ if (cloned.children && cloned.children.length > 0) {
93
+ existing.children = existing.children || [];
94
+ existing.children.push(...cloned.children);
95
+ existing.children = normalizeMenuTree(existing.children);
96
+ }
97
+ if (typeof cloned.sort === "number") {
98
+ existing.sort = cloned.sort;
99
+ }
100
+ if (cloned.name) {
101
+ existing.name = cloned.name;
102
+ }
103
+ } else {
104
+ map.set(menuPath, cloned);
105
+ }
106
+ }
107
+
108
+ const result = Array.from(map.values());
109
+ result.sort((a, b) => (a.sort || 1) - (b.sort || 1));
110
+ return result;
111
+ }
112
+
113
+ function extractScriptSetupBlock(vueContent: string): string | null {
114
+ // 只取第一个 <script ... setup ...> 块
115
+ const openTag = /<script\b[^>]*\bsetup\b[^>]*>/i.exec(vueContent);
116
+ if (!openTag) {
117
+ return null;
118
+ }
119
+
120
+ const start = openTag.index + openTag[0].length;
121
+ const closeIndex = vueContent.indexOf("</script>", start);
122
+ if (closeIndex < 0) {
123
+ return null;
124
+ }
125
+
126
+ return vueContent.slice(start, closeIndex);
127
+ }
128
+
129
+ function extractDefinePageMetaFromScriptSetup(scriptSetup: string): ViewDirMeta | null {
130
+ // 简化约束:
131
+ // - 每个页面只有一个 definePage
132
+ // - title 是纯字符串字面量
133
+ // - order 是数字字面量(可选)
134
+ // - 不考虑变量/表达式/多段 meta 组合
135
+
136
+ const titleMatch = scriptSetup.match(/definePage\s*\([\s\S]*?meta\s*:\s*\{[\s\S]*?title\s*:\s*(["'`])([^"'`]+)\1/);
137
+ if (!titleMatch) {
138
+ return null;
139
+ }
140
+
141
+ const orderMatch = scriptSetup.match(/definePage\s*\([\s\S]*?meta\s*:\s*\{[\s\S]*?order\s*:\s*(\d+)/);
142
+
143
+ return {
144
+ title: titleMatch[2],
145
+ order: orderMatch ? Number(orderMatch[1]) : undefined
146
+ };
37
147
  }
38
148
 
39
149
  /**
@@ -43,7 +153,7 @@ function cleanDirName(name: string): string {
43
153
  * @param parentPath 父级路径
44
154
  * @returns 菜单数组
45
155
  */
46
- async function scanViewsDir(viewsDir: string, prefix: string, parentPath: string = ''): Promise<MenuConfig[]> {
156
+ async function scanViewsDir(viewsDir: string, prefix: string, parentPath: string = ""): Promise<MenuConfig[]> {
47
157
  if (!existsSync(viewsDir)) {
48
158
  return [];
49
159
  }
@@ -53,43 +163,59 @@ async function scanViewsDir(viewsDir: string, prefix: string, parentPath: string
53
163
 
54
164
  for (const entry of entries) {
55
165
  // 只处理目录,忽略 components 目录
56
- if (!entry.isDirectory() || entry.name === 'components') {
166
+ if (!entry.isDirectory() || entry.name === "components") {
57
167
  continue;
58
168
  }
59
169
 
60
170
  const dirPath = join(viewsDir, entry.name);
61
- const metaPath = join(dirPath, 'meta.json');
171
+ const indexVuePath = join(dirPath, "index.vue");
62
172
 
63
- // 没有 meta.json 的目录不处理
64
- if (!existsSync(metaPath)) {
173
+ // 没有 index.vue 的目录不处理
174
+ if (!existsSync(indexVuePath)) {
65
175
  continue;
66
176
  }
67
177
 
68
- // 读取 meta.json
69
- let meta: { name: string; order?: number };
178
+ // index.vue 中解析 definePage({ meta })
179
+ let meta: ViewDirMeta | null = null;
70
180
  try {
71
- const content = await readFile(metaPath, 'utf-8');
72
- meta = JSON.parse(content);
181
+ const content = await readFile(indexVuePath, "utf-8");
182
+
183
+ const scriptSetup = extractScriptSetupBlock(content);
184
+ if (!scriptSetup) {
185
+ Logger.warn({ path: indexVuePath }, "index.vue 缺少 <script setup>,已跳过该目录菜单同步");
186
+ continue;
187
+ }
188
+
189
+ meta = extractDefinePageMetaFromScriptSetup(scriptSetup);
190
+ if (!meta?.title) {
191
+ Logger.warn({ path: indexVuePath }, "index.vue 未声明 definePage({ meta: { title, order? } }),已跳过该目录菜单同步");
192
+ continue;
193
+ }
73
194
  } catch (error: any) {
74
- Logger.warn({ err: error, path: metaPath }, '读取 meta.json 失败');
195
+ Logger.warn({ err: error, path: indexVuePath }, "读取 index.vue 失败");
196
+ continue;
197
+ }
198
+
199
+ // 没有 definePage meta 的目录不处理
200
+ if (!meta?.title) {
75
201
  continue;
76
202
  }
77
203
 
78
204
  // 计算路径:清理数字后缀,index 目录特殊处理
79
205
  const cleanName = cleanDirName(entry.name);
80
206
  let menuPath: string;
81
- if (cleanName === 'index') {
82
- // index 目录路径为父级路径,根级别则为 /
83
- menuPath = parentPath || '/';
207
+ if (cleanName === "index") {
208
+ // index 目录路径为父级路径;根级别用空字符串(避免 addon prefix 拼出尾随 /)
209
+ menuPath = parentPath;
84
210
  } else {
85
211
  menuPath = parentPath ? `${parentPath}/${cleanName}` : `/${cleanName}`;
86
212
  }
87
213
 
88
214
  // 添加 addon 前缀
89
- const fullPath = prefix ? `${prefix}${menuPath}` : menuPath;
215
+ const fullPath = prefix ? (menuPath ? `${prefix}${menuPath}` : prefix) : menuPath || "/";
90
216
 
91
217
  const menu: MenuConfig = {
92
- name: meta.name,
218
+ name: meta.title,
93
219
  path: fullPath,
94
220
  sort: meta.order || 1
95
221
  };
@@ -186,7 +312,7 @@ function collectPaths(menus: MenuConfig[]): Set<string> {
186
312
  * 递归同步单个菜单(无层级限制)
187
313
  */
188
314
  async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, existingMenuMap: Map<string, any>): Promise<number> {
189
- const existing = existingMenuMap.get(menu.path || '');
315
+ const existing = existingMenuMap.get(menu.path || "");
190
316
  let menuId: number;
191
317
 
192
318
  if (existing) {
@@ -196,7 +322,7 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
196
322
 
197
323
  if (needUpdate) {
198
324
  await helper.updData({
199
- table: 'addon_admin_menu',
325
+ table: "addon_admin_menu",
200
326
  where: { id: existing.id },
201
327
  data: {
202
328
  pid: pid,
@@ -207,11 +333,11 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
207
333
  }
208
334
  } else {
209
335
  menuId = await helper.insData({
210
- table: 'addon_admin_menu',
336
+ table: "addon_admin_menu",
211
337
  data: {
212
338
  pid: pid,
213
339
  name: menu.name,
214
- path: menu.path || '',
340
+ path: menu.path || "",
215
341
  sort: menu.sort || 1
216
342
  }
217
343
  });
@@ -231,7 +357,7 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
231
357
  */
232
358
  async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
233
359
  const allExistingMenus = await helper.getAll({
234
- table: 'addon_admin_menu'
360
+ table: "addon_admin_menu"
235
361
  });
236
362
  const existingMenuMap = new Map<string, any>();
237
363
  for (const menu of allExistingMenus.lists) {
@@ -244,7 +370,7 @@ async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
244
370
  try {
245
371
  await syncMenuRecursive(helper, menu, 0, existingMenuMap);
246
372
  } catch (error: any) {
247
- Logger.error({ err: error, menu: menu.name }, '同步菜单失败');
373
+ Logger.error({ err: error, menu: menu.name }, "同步菜单失败");
248
374
  throw error;
249
375
  }
250
376
  }
@@ -255,15 +381,15 @@ async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
255
381
  */
256
382
  async function deleteObsoleteRecords(helper: any, configPaths: Set<string>): Promise<void> {
257
383
  const allRecords = await helper.getAll({
258
- table: 'addon_admin_menu',
259
- fields: ['id', 'path'],
384
+ table: "addon_admin_menu",
385
+ fields: ["id", "path"],
260
386
  where: { state$gte: 0 }
261
387
  });
262
388
 
263
389
  for (const record of allRecords.lists) {
264
390
  if (record.path && !configPaths.has(record.path)) {
265
391
  await helper.delForce({
266
- table: 'addon_admin_menu',
392
+ table: "addon_admin_menu",
267
393
  where: { id: record.id }
268
394
  });
269
395
  }
@@ -281,8 +407,8 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: s
281
407
 
282
408
  for (const addonName of addonNames) {
283
409
  try {
284
- const addonDir = getAddonDir(addonName, '');
285
- const viewsDir = join(addonDir, 'views');
410
+ const addonDir = getAddonDir(addonName, "");
411
+ const viewsDir = join(addonDir, "views");
286
412
 
287
413
  if (existsSync(viewsDir)) {
288
414
  const prefix = `/addon/${addonName}`;
@@ -295,24 +421,24 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: s
295
421
  }
296
422
  }
297
423
  } catch (error: any) {
298
- Logger.warn({ err: error, addon: addonName }, '扫描 addon views 目录失败');
424
+ Logger.warn({ err: error, addon: addonName }, "扫描 addon views 目录失败");
299
425
  }
300
426
  }
301
427
 
302
428
  // 2. 读取项目的 menus.json
303
- const menusJsonPath = join(projectDir, 'menus.json');
429
+ const menusJsonPath = join(projectDir, "menus.json");
304
430
  if (existsSync(menusJsonPath)) {
305
431
  try {
306
- const content = await readFile(menusJsonPath, 'utf-8');
432
+ const content = await readFile(menusJsonPath, "utf-8");
307
433
  const projectMenus = JSON.parse(content);
308
434
  if (Array.isArray(projectMenus) && projectMenus.length > 0) {
309
435
  allMenus.push({
310
436
  menus: projectMenus,
311
- source: 'project'
437
+ source: "project"
312
438
  });
313
439
  }
314
440
  } catch (error: any) {
315
- Logger.warn({ err: error }, '读取项目 menus.json 失败');
441
+ Logger.warn({ err: error }, "读取项目 menus.json 失败");
316
442
  }
317
443
  }
318
444
 
@@ -325,7 +451,7 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: s
325
451
  export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<void> {
326
452
  try {
327
453
  if (options.plan) {
328
- Logger.debug('[计划] 同步菜单配置到数据库(plan 模式不执行)');
454
+ Logger.debug("[计划] 同步菜单配置到数据库(plan 模式不执行)");
329
455
  return;
330
456
  }
331
457
 
@@ -335,11 +461,15 @@ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<vo
335
461
  // 2. 合并菜单配置
336
462
  let mergedMenus = mergeMenuConfigs(allMenus);
337
463
 
464
+ // 2.1 规范化并去重(防止尾随 / 或多 / 导致隐藏菜单与 DB 同步异常)
465
+ mergedMenus = normalizeMenuTree(mergedMenus);
466
+
338
467
  // 3. 过滤隐藏菜单(根据 hiddenMenus 配置)
339
468
  const hiddenMenus = (beflyConfig as any).hiddenMenus || [];
340
469
  if (Array.isArray(hiddenMenus) && hiddenMenus.length > 0) {
341
- const hiddenSet = new Set(hiddenMenus);
470
+ const hiddenSet = new Set(hiddenMenus.map((item: string) => normalizeMenuPath(item)));
342
471
  mergedMenus = filterHiddenMenus(mergedMenus, hiddenSet);
472
+ mergedMenus = normalizeMenuTree(mergedMenus);
343
473
  }
344
474
 
345
475
  // 连接数据库
@@ -348,10 +478,10 @@ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<vo
348
478
  const helper = new DbHelper({ redis: new RedisHelper() } as any, Connect.getSql());
349
479
 
350
480
  // 3. 检查表是否存在
351
- const exists = await helper.tableExists('addon_admin_menu');
481
+ const exists = await helper.tableExists("addon_admin_menu");
352
482
 
353
483
  if (!exists) {
354
- Logger.debug('表 addon_admin_menu 不存在,跳过菜单同步');
484
+ Logger.debug("表 addon_admin_menu 不存在,跳过菜单同步");
355
485
  return;
356
486
  }
357
487
 
@@ -366,21 +496,26 @@ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<vo
366
496
 
367
497
  // 7. 获取最终菜单数据(用于缓存)
368
498
  const allMenusData = await helper.getAll({
369
- table: 'addon_admin_menu',
370
- orderBy: ['sort#ASC', 'id#ASC']
499
+ table: "addon_admin_menu"
371
500
  });
372
501
 
373
502
  // 8. 缓存菜单数据到 Redis
374
503
  try {
375
504
  const redisHelper = new RedisHelper();
376
- await redisHelper.setObject(RedisKeys.menusAll(), allMenusData.lists);
505
+ await redisHelper.setObject(CacheKeys.menusAll(), allMenusData.lists);
377
506
  } catch (error: any) {
378
- Logger.warn({ err: error }, 'Redis 缓存菜单数据失败');
507
+ Logger.warn({ err: error }, "Redis 缓存菜单数据失败");
379
508
  }
380
509
  } catch (error: any) {
381
- Logger.error({ err: error }, '菜单同步失败');
510
+ Logger.error({ err: error }, "菜单同步失败");
382
511
  throw error;
383
512
  } finally {
384
513
  await Connect.disconnect();
385
514
  }
386
515
  }
516
+
517
+ // 仅测试用(避免将内部扫描逻辑变成稳定 API)
518
+ export const __test__ = {
519
+ scanViewsDir: scanViewsDir,
520
+ normalizeMenuPath: normalizeMenuPath
521
+ };