befly 3.9.37 → 3.9.39

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 +38 -39
  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} +225 -235
  8. package/docs/cipher.md +71 -69
  9. package/docs/database.md +155 -153
  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} +7 -7
  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 +15 -7
  34. package/lib/asyncContext.ts +43 -0
  35. package/lib/cacheHelper.ts +212 -81
  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 +211 -109
  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 +53 -47
  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 -54
  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 -66
  75. package/sync/syncMenu.ts +190 -57
  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,8 +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',
235
- fields: ['id', 'pid', 'name', 'path', 'sort']
360
+ table: "addon_admin_menu"
236
361
  });
237
362
  const existingMenuMap = new Map<string, any>();
238
363
  for (const menu of allExistingMenus.lists) {
@@ -245,7 +370,7 @@ async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
245
370
  try {
246
371
  await syncMenuRecursive(helper, menu, 0, existingMenuMap);
247
372
  } catch (error: any) {
248
- Logger.error({ err: error, menu: menu.name }, '同步菜单失败');
373
+ Logger.error({ err: error, menu: menu.name }, "同步菜单失败");
249
374
  throw error;
250
375
  }
251
376
  }
@@ -256,15 +381,15 @@ async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
256
381
  */
257
382
  async function deleteObsoleteRecords(helper: any, configPaths: Set<string>): Promise<void> {
258
383
  const allRecords = await helper.getAll({
259
- table: 'addon_admin_menu',
260
- fields: ['id', 'path'],
384
+ table: "addon_admin_menu",
385
+ fields: ["id", "path"],
261
386
  where: { state$gte: 0 }
262
387
  });
263
388
 
264
389
  for (const record of allRecords.lists) {
265
390
  if (record.path && !configPaths.has(record.path)) {
266
391
  await helper.delForce({
267
- table: 'addon_admin_menu',
392
+ table: "addon_admin_menu",
268
393
  where: { id: record.id }
269
394
  });
270
395
  }
@@ -282,8 +407,8 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: s
282
407
 
283
408
  for (const addonName of addonNames) {
284
409
  try {
285
- const addonDir = getAddonDir(addonName, '');
286
- const viewsDir = join(addonDir, 'views');
410
+ const addonDir = getAddonDir(addonName, "");
411
+ const viewsDir = join(addonDir, "views");
287
412
 
288
413
  if (existsSync(viewsDir)) {
289
414
  const prefix = `/addon/${addonName}`;
@@ -296,24 +421,24 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: s
296
421
  }
297
422
  }
298
423
  } catch (error: any) {
299
- Logger.warn({ err: error, addon: addonName }, '扫描 addon views 目录失败');
424
+ Logger.warn({ err: error, addon: addonName }, "扫描 addon views 目录失败");
300
425
  }
301
426
  }
302
427
 
303
428
  // 2. 读取项目的 menus.json
304
- const menusJsonPath = join(projectDir, 'menus.json');
429
+ const menusJsonPath = join(projectDir, "menus.json");
305
430
  if (existsSync(menusJsonPath)) {
306
431
  try {
307
- const content = await readFile(menusJsonPath, 'utf-8');
432
+ const content = await readFile(menusJsonPath, "utf-8");
308
433
  const projectMenus = JSON.parse(content);
309
434
  if (Array.isArray(projectMenus) && projectMenus.length > 0) {
310
435
  allMenus.push({
311
436
  menus: projectMenus,
312
- source: 'project'
437
+ source: "project"
313
438
  });
314
439
  }
315
440
  } catch (error: any) {
316
- Logger.warn({ err: error }, '读取项目 menus.json 失败');
441
+ Logger.warn({ err: error }, "读取项目 menus.json 失败");
317
442
  }
318
443
  }
319
444
 
@@ -326,7 +451,7 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: s
326
451
  export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<void> {
327
452
  try {
328
453
  if (options.plan) {
329
- Logger.debug('[计划] 同步菜单配置到数据库(plan 模式不执行)');
454
+ Logger.debug("[计划] 同步菜单配置到数据库(plan 模式不执行)");
330
455
  return;
331
456
  }
332
457
 
@@ -336,11 +461,15 @@ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<vo
336
461
  // 2. 合并菜单配置
337
462
  let mergedMenus = mergeMenuConfigs(allMenus);
338
463
 
464
+ // 2.1 规范化并去重(防止尾随 / 或多 / 导致隐藏菜单与 DB 同步异常)
465
+ mergedMenus = normalizeMenuTree(mergedMenus);
466
+
339
467
  // 3. 过滤隐藏菜单(根据 hiddenMenus 配置)
340
468
  const hiddenMenus = (beflyConfig as any).hiddenMenus || [];
341
469
  if (Array.isArray(hiddenMenus) && hiddenMenus.length > 0) {
342
- const hiddenSet = new Set(hiddenMenus);
470
+ const hiddenSet = new Set(hiddenMenus.map((item: string) => normalizeMenuPath(item)));
343
471
  mergedMenus = filterHiddenMenus(mergedMenus, hiddenSet);
472
+ mergedMenus = normalizeMenuTree(mergedMenus);
344
473
  }
345
474
 
346
475
  // 连接数据库
@@ -349,10 +478,10 @@ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<vo
349
478
  const helper = new DbHelper({ redis: new RedisHelper() } as any, Connect.getSql());
350
479
 
351
480
  // 3. 检查表是否存在
352
- const exists = await helper.tableExists('addon_admin_menu');
481
+ const exists = await helper.tableExists("addon_admin_menu");
353
482
 
354
483
  if (!exists) {
355
- Logger.debug('表 addon_admin_menu 不存在,跳过菜单同步');
484
+ Logger.debug("表 addon_admin_menu 不存在,跳过菜单同步");
356
485
  return;
357
486
  }
358
487
 
@@ -367,22 +496,26 @@ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<vo
367
496
 
368
497
  // 7. 获取最终菜单数据(用于缓存)
369
498
  const allMenusData = await helper.getAll({
370
- table: 'addon_admin_menu',
371
- fields: ['id', 'pid', 'name', 'path', 'sort'],
372
- orderBy: ['sort#ASC', 'id#ASC']
499
+ table: "addon_admin_menu"
373
500
  });
374
501
 
375
502
  // 8. 缓存菜单数据到 Redis
376
503
  try {
377
504
  const redisHelper = new RedisHelper();
378
- await redisHelper.setObject(RedisKeys.menusAll(), allMenusData.lists);
505
+ await redisHelper.setObject(CacheKeys.menusAll(), allMenusData.lists);
379
506
  } catch (error: any) {
380
- Logger.warn({ err: error }, 'Redis 缓存菜单数据失败');
507
+ Logger.warn({ err: error }, "Redis 缓存菜单数据失败");
381
508
  }
382
509
  } catch (error: any) {
383
- Logger.error({ err: error }, '菜单同步失败');
510
+ Logger.error({ err: error }, "菜单同步失败");
384
511
  throw error;
385
512
  } finally {
386
513
  await Connect.disconnect();
387
514
  }
388
515
  }
516
+
517
+ // 仅测试用(避免将内部扫描逻辑变成稳定 API)
518
+ export const __test__ = {
519
+ scanViewsDir: scanViewsDir,
520
+ normalizeMenuPath: normalizeMenuPath
521
+ };