befly 3.9.40 → 3.10.1

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 (144) hide show
  1. package/README.md +47 -19
  2. package/befly.config.ts +19 -2
  3. package/checks/checkApi.ts +79 -77
  4. package/checks/checkHook.ts +48 -0
  5. package/checks/checkMenu.ts +168 -0
  6. package/checks/checkPlugin.ts +48 -0
  7. package/checks/checkTable.ts +137 -183
  8. package/docs/README.md +17 -11
  9. package/docs/api/api.md +16 -2
  10. package/docs/guide/quickstart.md +31 -10
  11. package/docs/hooks/hook.md +2 -2
  12. package/docs/hooks/rateLimit.md +1 -1
  13. package/docs/infra/redis.md +26 -14
  14. package/docs/plugins/plugin.md +23 -21
  15. package/docs/quickstart.md +5 -328
  16. package/docs/reference/addon.md +0 -4
  17. package/docs/reference/config.md +14 -31
  18. package/docs/reference/logger.md +3 -3
  19. package/docs/reference/sync.md +132 -237
  20. package/docs/reference/table.md +28 -30
  21. package/hooks/auth.ts +3 -4
  22. package/hooks/cors.ts +4 -6
  23. package/hooks/parser.ts +3 -4
  24. package/hooks/permission.ts +3 -4
  25. package/hooks/validator.ts +3 -4
  26. package/lib/cacheHelper.ts +89 -153
  27. package/lib/cacheKeys.ts +1 -1
  28. package/lib/connect.ts +9 -13
  29. package/lib/dbDialect.ts +285 -0
  30. package/lib/dbHelper.ts +179 -507
  31. package/lib/dbUtils.ts +450 -0
  32. package/lib/logger.ts +41 -5
  33. package/lib/redisHelper.ts +1 -0
  34. package/lib/sqlBuilder.ts +358 -58
  35. package/lib/sqlCheck.ts +136 -0
  36. package/lib/validator.ts +1 -1
  37. package/loader/loadApis.ts +23 -126
  38. package/loader/loadHooks.ts +31 -46
  39. package/loader/loadPlugins.ts +37 -52
  40. package/main.ts +58 -19
  41. package/package.json +24 -25
  42. package/paths.ts +14 -14
  43. package/plugins/cache.ts +12 -6
  44. package/plugins/cipher.ts +2 -2
  45. package/plugins/config.ts +6 -8
  46. package/plugins/db.ts +14 -19
  47. package/plugins/jwt.ts +6 -7
  48. package/plugins/logger.ts +7 -9
  49. package/plugins/redis.ts +8 -10
  50. package/plugins/tool.ts +3 -4
  51. package/router/api.ts +3 -2
  52. package/router/static.ts +7 -5
  53. package/sync/syncApi.ts +80 -235
  54. package/sync/syncCache.ts +16 -0
  55. package/sync/syncDev.ts +167 -202
  56. package/sync/syncMenu.ts +230 -444
  57. package/sync/syncTable.ts +1247 -0
  58. package/tests/_mocks/mockSqliteDb.ts +204 -0
  59. package/tests/addonHelper-cache.test.ts +32 -0
  60. package/tests/apiHandler-routePath-only.test.ts +32 -0
  61. package/tests/cacheHelper.test.ts +16 -51
  62. package/tests/checkApi-routePath-strict.test.ts +166 -0
  63. package/tests/checkMenu.test.ts +346 -0
  64. package/tests/checkTable-smoke.test.ts +157 -0
  65. package/tests/dbDialect-cache.test.ts +23 -0
  66. package/tests/dbDialect.test.ts +46 -0
  67. package/tests/dbHelper-advanced.test.ts +1 -1
  68. package/tests/dbHelper-all-array-types.test.ts +15 -15
  69. package/tests/dbHelper-batch-write.test.ts +90 -0
  70. package/tests/dbHelper-columns.test.ts +36 -54
  71. package/tests/dbHelper-execute.test.ts +26 -26
  72. package/tests/dbHelper-joins.test.ts +85 -176
  73. package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
  74. package/tests/fixtures/scanFilesApis/a.ts +3 -0
  75. package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
  76. package/tests/loadPlugins-order-smoke.test.ts +75 -0
  77. package/tests/logger.test.ts +6 -6
  78. package/tests/redisHelper.test.ts +6 -1
  79. package/tests/scanFiles-routePath.test.ts +46 -0
  80. package/tests/smoke-sql.test.ts +24 -0
  81. package/tests/sqlBuilder-advanced.test.ts +18 -5
  82. package/tests/sqlBuilder.test.ts +24 -0
  83. package/tests/sync-init-guard.test.ts +105 -0
  84. package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
  85. package/tests/syncApi-obsolete-records.test.ts +69 -0
  86. package/tests/syncApi-type-compat.test.ts +72 -0
  87. package/tests/syncDev-permissions.test.ts +81 -0
  88. package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
  89. package/tests/syncMenu-duplicate-path.test.ts +122 -0
  90. package/tests/syncMenu-obsolete-records.test.ts +161 -0
  91. package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
  92. package/tests/syncMenu-paths.test.ts +0 -9
  93. package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
  94. package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
  95. package/tests/syncTable-constants.test.ts +101 -0
  96. package/tests/syncTable-db-integration.test.ts +237 -0
  97. package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
  98. package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
  99. package/tests/syncTable-schema.test.ts +99 -0
  100. package/tests/syncTable-testkit.test.ts +25 -0
  101. package/tests/syncTable-types.test.ts +122 -0
  102. package/tests/tableRef-and-deserialize.test.ts +67 -0
  103. package/tsconfig.json +1 -1
  104. package/types/api.d.ts +1 -1
  105. package/types/befly.d.ts +13 -12
  106. package/types/cache.d.ts +2 -2
  107. package/types/context.d.ts +1 -1
  108. package/types/database.d.ts +0 -5
  109. package/types/hook.d.ts +1 -10
  110. package/types/plugin.d.ts +2 -96
  111. package/types/sync.d.ts +19 -25
  112. package/utils/convertBigIntFields.ts +38 -0
  113. package/utils/disableMenusGlob.ts +85 -0
  114. package/utils/importDefault.ts +21 -0
  115. package/utils/isDirentDirectory.ts +23 -0
  116. package/utils/loadMenuConfigs.ts +145 -0
  117. package/utils/processFields.ts +25 -0
  118. package/utils/scanAddons.ts +72 -0
  119. package/utils/scanFiles.ts +129 -21
  120. package/utils/scanSources.ts +64 -0
  121. package/utils/sortModules.ts +137 -0
  122. package/checks/checkApp.ts +0 -55
  123. package/docs/cipher.md +0 -582
  124. package/docs/database.md +0 -1176
  125. package/hooks/rateLimit.ts +0 -276
  126. package/sync/syncAll.ts +0 -35
  127. package/sync/syncDb/apply.ts +0 -192
  128. package/sync/syncDb/constants.ts +0 -119
  129. package/sync/syncDb/ddl.ts +0 -251
  130. package/sync/syncDb/helpers.ts +0 -84
  131. package/sync/syncDb/schema.ts +0 -202
  132. package/sync/syncDb/sqlite.ts +0 -48
  133. package/sync/syncDb/table.ts +0 -207
  134. package/sync/syncDb/tableCreate.ts +0 -163
  135. package/sync/syncDb/types.ts +0 -132
  136. package/sync/syncDb/version.ts +0 -69
  137. package/sync/syncDb.ts +0 -168
  138. package/tests/rateLimit-hook.test.ts +0 -477
  139. package/tests/syncDb-constants.test.ts +0 -130
  140. package/tests/syncDb-schema.test.ts +0 -179
  141. package/tests/syncDb-types.test.ts +0 -139
  142. package/utils/addonHelper.ts +0 -90
  143. package/utils/modules.ts +0 -98
  144. package/utils/route.ts +0 -23
package/sync/syncMenu.ts CHANGED
@@ -1,521 +1,307 @@
1
- /**
2
- * SyncMenu 命令 - 同步菜单数据到数据库
3
- * 说明:扫描 addon 的 views 目录和项目的 menus.json,同步菜单数据
4
- *
5
- * 流程:
6
- * 1. 扫描所有 addon 的 views 目录下的 index.vue,并从 definePage({ meta }) 解析菜单元信息
7
- * 2. 根据目录层级构建菜单树(无层级限制)
8
- * 3. 读取项目的 menus.json 文件(手动配置的菜单)
9
- * 4. 根据菜单的 path 字段检查是否存在
10
- * 5. 存在则更新其他字段(name、sort、pid)
11
- * 6. 不存在则新增菜单记录
12
- * 7. 强制删除配置中不存在的菜单记录
13
- * 注:state 字段由框架自动管理(1=正常,2=禁用,0=删除)
14
- */
15
-
16
- import type { SyncMenuOptions, MenuConfig } from "../types/sync.js";
17
-
18
- import { existsSync } from "node:fs";
19
- import { readdir, readFile } from "node:fs/promises";
20
-
21
- import { join } from "pathe";
22
-
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";
31
-
32
- /**
33
- * 清理目录名中的数字后缀
34
- * 如:login_1 → login, index_2 → index
35
- */
36
- function cleanDirName(name: string): string {
37
- return name.replace(/_\d+$/, "");
38
- }
1
+ import type { BeflyContext } from "../types/befly.js";
2
+ import type { MenuConfig } from "../types/sync.js";
39
3
 
40
- type ViewDirMeta = {
41
- title: string;
42
- order?: number;
4
+ import { Logger } from "../lib/logger.js";
5
+ import { compileDisableMenuGlobRules, isMenuPathDisabledByGlobRules } from "../utils/disableMenusGlob.js";
6
+ import { getParentPath } from "../utils/loadMenuConfigs.js";
7
+
8
+ type MenuDef = {
9
+ path: string;
10
+ name: string;
11
+ sort: number;
12
+ parentPath: string;
43
13
  };
44
14
 
45
- function normalizeMenuPath(path: string): string {
46
- // 约束:统一 path 形态,避免隐藏菜单匹配、DB 同步出现重复
47
- // - 必须以 / 开头
48
- // - 折叠多个 /
49
- // - 去掉尾随 /(根 / 除外)
50
- let result = path;
51
-
52
- if (!result) {
53
- return "/";
15
+ function flattenMenusToDefMap(mergedMenus: MenuConfig[]): Map<string, MenuDef> {
16
+ // 读取配置菜单:扁平化为 path => { name, sort, parentPath }
17
+ // - path 为唯一键:后出现的覆盖先出现的(与旧逻辑“同 path 多次同步同一条记录”一致)
18
+ // parentPath 规则:
19
+ // 1) menu 显式携带 parentPath(包括空字符串),以其为准
20
+ // 2) 否则使用“树结构”推导的父级(由 children 嵌套关系决定;根级为 "")
21
+ // 3) 保底:若无法推导(极端情况),回退到 getParentPath(path)
22
+ const menuDefMap = new Map<string, MenuDef>();
23
+
24
+ const stack: Array<{ menu: MenuConfig; parentPathFromTree: string }> = [];
25
+ for (const m of mergedMenus) {
26
+ stack.push({ menu: m, parentPathFromTree: "" });
54
27
  }
55
28
 
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>();
29
+ while (stack.length > 0) {
30
+ const item = stack.pop();
31
+ const menu = item ? item.menu : null;
32
+ if (!menu) {
33
+ continue;
34
+ }
72
35
 
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
- };
36
+ const path = typeof (menu as any).path === "string" ? (menu as any).path : "";
80
37
 
81
- if (menu.children && menu.children.length > 0) {
82
- cloned.children = normalizeMenuTree(menu.children);
38
+ const rawChildren = (menu as any).children;
39
+ if (rawChildren && Array.isArray(rawChildren) && rawChildren.length > 0) {
40
+ const nextParentPathFromTree = typeof path === "string" ? path : "";
41
+ for (const child of rawChildren) {
42
+ stack.push({ menu: child, parentPathFromTree: nextParentPathFromTree });
43
+ }
83
44
  }
84
45
 
85
- if (!menuPath) {
86
- // path 缺失的菜单无法参与同步/去重,直接丢弃
46
+ if (!path) {
87
47
  continue;
88
48
  }
89
49
 
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);
50
+ const name = typeof (menu as any).name === "string" ? (menu as any).name : "";
51
+ if (!name) {
52
+ continue;
105
53
  }
106
- }
107
54
 
108
- const result = Array.from(map.values());
109
- result.sort((a, b) => (a.sort || 1) - (b.sort || 1));
110
- return result;
111
- }
55
+ const sort = typeof (menu as any).sort === "number" ? (menu as any).sort : 999999;
112
56
 
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
- }
57
+ const hasExplicitParentPath = typeof (menu as any).parentPath === "string";
58
+ const parentPath = hasExplicitParentPath ? ((menu as any).parentPath as string) : typeof item?.parentPathFromTree === "string" ? item.parentPathFromTree : getParentPath(path);
119
59
 
120
- const start = openTag.index + openTag[0].length;
121
- const closeIndex = vueContent.indexOf("</script>", start);
122
- if (closeIndex < 0) {
123
- return null;
60
+ menuDefMap.set(path, {
61
+ path: path,
62
+ name: name,
63
+ sort: sort,
64
+ parentPath: parentPath
65
+ });
124
66
  }
125
67
 
126
- return vueContent.slice(start, closeIndex);
68
+ return menuDefMap;
127
69
  }
128
70
 
129
- function extractDefinePageMetaFromScriptSetup(scriptSetup: string): ViewDirMeta | null {
130
- // 简化约束:
131
- // - 每个页面只有一个 definePage
132
- // - title 是纯字符串字面量
133
- // - order 是数字字面量(可选)
134
- // - 不考虑变量/表达式/多段 meta 组合
71
+ export async function syncMenu(ctx: BeflyContext, mergedMenus: MenuConfig[]): Promise<void> {
72
+ if (!ctx.db) {
73
+ throw new Error("syncMenu: ctx.db 未初始化(Db 插件未加载或注入失败)");
74
+ }
135
75
 
136
- const titleMatch = scriptSetup.match(/definePage\s*\([\s\S]*?meta\s*:\s*\{[\s\S]*?title\s*:\s*(["'`])([^"'`]+)\1/);
137
- if (!titleMatch) {
138
- return null;
76
+ if (!ctx.cache) {
77
+ throw new Error("syncMenu: ctx.cache 未初始化(cache 插件未加载或注入失败)");
139
78
  }
140
79
 
141
- const orderMatch = scriptSetup.match(/definePage\s*\([\s\S]*?meta\s*:\s*\{[\s\S]*?order\s*:\s*(\d+)/);
80
+ if (!ctx.config) {
81
+ throw new Error("syncMenu: ctx.config 未初始化(config 插件未加载或注入失败)");
82
+ }
142
83
 
143
- return {
144
- title: titleMatch[2],
145
- order: orderMatch ? Number(orderMatch[1]) : undefined
146
- };
147
- }
84
+ if (!(await ctx.db.tableExists("addon_admin_menu"))) {
85
+ Logger.debug(`addon_admin_menu 表不存在`);
86
+ return;
87
+ }
148
88
 
149
- /**
150
- * 扫描 views 目录,构建菜单树
151
- * @param viewsDir views 目录路径
152
- * @param prefix 路径前缀(addon 前缀)
153
- * @param parentPath 父级路径
154
- * @returns 菜单数组
155
- */
156
- async function scanViewsDir(viewsDir: string, prefix: string, parentPath: string = ""): Promise<MenuConfig[]> {
157
- if (!existsSync(viewsDir)) {
158
- return [];
89
+ // 防御性过滤:保证禁用菜单不会进入 DB(即使上游遗漏了 checkMenu 的过滤)
90
+ const disableRules = compileDisableMenuGlobRules(ctx.config?.disableMenus);
91
+ const filteredMergedMenus: MenuConfig[] =
92
+ disableRules.length === 0
93
+ ? mergedMenus
94
+ : (() => {
95
+ const filterMenusByDisableRules = (menus: MenuConfig[]): MenuConfig[] => {
96
+ const filtered: MenuConfig[] = [];
97
+
98
+ for (const menu of menus) {
99
+ const menuPath = typeof (menu as any)?.path === "string" ? String((menu as any).path).trim() : "";
100
+ if (menuPath && isMenuPathDisabledByGlobRules(menuPath, disableRules)) {
101
+ continue;
102
+ }
103
+
104
+ const children = Array.isArray((menu as any)?.children) ? ((menu as any).children as MenuConfig[]) : null;
105
+ if (children && children.length > 0) {
106
+ const nextChildren = filterMenusByDisableRules(children);
107
+ if (nextChildren.length > 0) {
108
+ (menu as any).children = nextChildren;
109
+ } else {
110
+ delete (menu as any).children;
111
+ }
112
+ }
113
+
114
+ filtered.push(menu);
115
+ }
116
+
117
+ return filtered;
118
+ };
119
+
120
+ return filterMenusByDisableRules(mergedMenus);
121
+ })();
122
+
123
+ const menuDefMap = flattenMenusToDefMap(filteredMergedMenus);
124
+
125
+ const configPaths = new Set<string>();
126
+ for (const p of menuDefMap.keys()) {
127
+ configPaths.add(p);
159
128
  }
160
129
 
161
- const menus: MenuConfig[] = [];
162
- const entries = await readdir(viewsDir, { withFileTypes: true });
130
+ const tableName = "addon_admin_menu";
163
131
 
164
- for (const entry of entries) {
165
- // 只处理目录,忽略 components 目录
166
- if (!entry.isDirectory() || entry.name === "components") {
167
- continue;
168
- }
132
+ // 2) 批量同步(事务内):按 path diff 执行批量 insert/update/delete
133
+ await ctx.db.trans(async (trans: any) => {
134
+ // 读取全部菜单(用于清理禁用菜单:不分 state)
135
+ const allExistingMenusAllState = await trans.getAll({
136
+ table: tableName,
137
+ fields: ["id", "name", "path", "parentPath", "sort", "state"]
138
+ } as any);
169
139
 
170
- const dirPath = join(viewsDir, entry.name);
171
- const indexVuePath = join(dirPath, "index.vue");
140
+ const existingListAllState = allExistingMenusAllState.lists || [];
141
+ const existingList = existingListAllState.filter((m: any) => typeof m?.state === "number" && m.state >= 0);
172
142
 
173
- // 没有 index.vue 的目录不处理
174
- if (!existsSync(indexVuePath)) {
175
- continue;
176
- }
143
+ const existingMenuMap = new Map<string, any>();
144
+ const duplicateIdSet = new Set<number>();
145
+ const duplicatePathInfoMap = new Map<string, { keptId: number; removedIds: number[] }>();
177
146
 
178
- // index.vue 中解析 definePage({ meta })
179
- let meta: ViewDirMeta | null = null;
180
- try {
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>,已跳过该目录菜单同步");
147
+ for (const record of existingList) {
148
+ if (typeof record?.path !== "string" || !record.path) {
149
+ continue;
150
+ }
151
+ if (typeof record?.id !== "number") {
186
152
  continue;
187
153
  }
188
154
 
189
- meta = extractDefinePageMetaFromScriptSetup(scriptSetup);
190
- if (!meta?.title) {
191
- Logger.warn({ path: indexVuePath }, "index.vue 未声明 definePage({ meta: { title, order? } }),已跳过该目录菜单同步");
155
+ const existing = existingMenuMap.get(record.path);
156
+ if (!existing) {
157
+ existingMenuMap.set(record.path, record);
192
158
  continue;
193
159
  }
194
- } catch (error: any) {
195
- Logger.warn({ err: error, path: indexVuePath }, "读取 index.vue 失败");
196
- continue;
197
- }
198
160
 
199
- // 没有 definePage meta 的目录不处理
200
- if (!meta?.title) {
201
- continue;
202
- }
161
+ const existingId = typeof existing?.id === "number" ? existing.id : 0;
162
+ const recordId = record.id;
203
163
 
204
- // 计算路径:清理数字后缀,index 目录特殊处理
205
- const cleanName = cleanDirName(entry.name);
206
- let menuPath: string;
207
- if (cleanName === "index") {
208
- // index 目录路径为父级路径;根级别用空字符串(避免 addon prefix 拼出尾随 /)
209
- menuPath = parentPath;
210
- } else {
211
- menuPath = parentPath ? `${parentPath}/${cleanName}` : `/${cleanName}`;
212
- }
164
+ // 保留 id 最大的一条(genTimeID 越大通常越新),其余标记为重复并清理
165
+ if (recordId > existingId) {
166
+ existingMenuMap.set(record.path, record);
213
167
 
214
- // 添加 addon 前缀
215
- const fullPath = prefix ? (menuPath ? `${prefix}${menuPath}` : prefix) : menuPath || "/";
168
+ if (existingId > 0) {
169
+ duplicateIdSet.add(existingId);
170
+ }
216
171
 
217
- const menu: MenuConfig = {
218
- name: meta.title,
219
- path: fullPath,
220
- sort: meta.order || 1
221
- };
172
+ const info = duplicatePathInfoMap.get(record.path) || { keptId: recordId, removedIds: [] as number[] };
173
+ info.keptId = recordId;
174
+ if (existingId > 0) {
175
+ info.removedIds.push(existingId);
176
+ }
177
+ duplicatePathInfoMap.set(record.path, info);
178
+ } else {
179
+ if (recordId > 0) {
180
+ duplicateIdSet.add(recordId);
181
+ }
222
182
 
223
- // 递归扫描子目录
224
- const children = await scanViewsDir(dirPath, prefix, menuPath);
225
- if (children.length > 0) {
226
- menu.children = children;
183
+ const info = duplicatePathInfoMap.get(record.path) || { keptId: existingId, removedIds: [] as number[] };
184
+ info.keptId = existingId;
185
+ if (recordId > 0) {
186
+ info.removedIds.push(recordId);
187
+ }
188
+ duplicatePathInfoMap.set(record.path, info);
189
+ }
227
190
  }
228
191
 
229
- menus.push(menu);
230
- }
231
-
232
- // sort 排序
233
- menus.sort((a, b) => (a.sort || 1) - (b.sort || 1));
234
-
235
- return menus;
236
- }
192
+ if (duplicatePathInfoMap.size > 0) {
193
+ const examples: Array<{ path: string; keptId: number; removedIds: number[] }> = [];
194
+ for (const entry of duplicatePathInfoMap.entries()) {
195
+ const path = entry[0];
196
+ const info = entry[1];
197
+ examples.push({ path: path, keptId: info.keptId, removedIds: info.removedIds });
198
+ if (examples.length >= 10) {
199
+ break;
200
+ }
201
+ }
237
202
 
238
- /**
239
- * 合并菜单配置
240
- * 支持无限层级菜单结构
241
- */
242
- function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; source: string }>): MenuConfig[] {
243
- const menuMap = new Map<string, MenuConfig>();
203
+ Logger.warn(
204
+ {
205
+ table: tableName,
206
+ duplicatePaths: duplicatePathInfoMap.size,
207
+ duplicateIds: duplicateIdSet.size,
208
+ examples: examples
209
+ },
210
+ "addon_admin_menu 检测到重复 path 记录:已保留 id 最大的一条并删除其余记录"
211
+ );
212
+ }
244
213
 
245
- for (const { menus } of allMenus) {
246
- for (const menu of menus) {
247
- if (!menu.path) continue;
214
+ // 2) 一次性算出 insert/update(仅依赖 path diff,不使用 pid,不预生成 id)
215
+ const updList: Array<{ id: number; data: Record<string, any> }> = [];
216
+ const insList: Array<Record<string, any>> = [];
248
217
 
249
- const existing = menuMap.get(menu.path);
218
+ for (const def of menuDefMap.values()) {
219
+ const existing = existingMenuMap.get(def.path);
250
220
  if (existing) {
251
- // 合并子菜单
252
- if (menu.children && menu.children.length > 0) {
253
- existing.children = existing.children || [];
254
- existing.children.push(...menu.children);
221
+ const existingParentPath = typeof existing.parentPath === "string" ? existing.parentPath : "";
222
+ const needUpdate = existing.name !== def.name || existing.sort !== def.sort || existingParentPath !== def.parentPath;
223
+ if (needUpdate) {
224
+ updList.push({
225
+ id: existing.id,
226
+ data: {
227
+ name: def.name,
228
+ path: def.path,
229
+ parentPath: def.parentPath,
230
+ sort: def.sort
231
+ }
232
+ });
255
233
  }
256
234
  } else {
257
- menuMap.set(menu.path, { ...menu });
235
+ insList.push({
236
+ name: def.name,
237
+ path: def.path,
238
+ parentPath: def.parentPath,
239
+ sort: def.sort
240
+ });
258
241
  }
259
242
  }
260
- }
261
-
262
- return Array.from(menuMap.values());
263
- }
264
243
 
265
- /**
266
- * 过滤隐藏的菜单(递归处理子菜单)
267
- */
268
- function filterHiddenMenus(menus: MenuConfig[], hiddenSet: Set<string>): MenuConfig[] {
269
- const result: MenuConfig[] = [];
270
-
271
- for (const menu of menus) {
272
- // 如果菜单在隐藏列表中,跳过
273
- if (menu.path && hiddenSet.has(menu.path)) {
274
- continue;
244
+ if (updList.length > 0) {
245
+ await trans.updBatch(tableName, updList);
275
246
  }
276
247
 
277
- const filtered = { ...menu };
278
-
279
- // 递归过滤子菜单
280
- if (filtered.children && filtered.children.length > 0) {
281
- filtered.children = filterHiddenMenus(filtered.children, hiddenSet);
248
+ if (insList.length > 0) {
249
+ await trans.insBatch(tableName, insList);
282
250
  }
283
251
 
284
- result.push(filtered);
285
- }
286
-
287
- return result;
288
- }
289
-
290
- /**
291
- * 收集所有菜单的 path(递归收集所有层级)
292
- */
293
- function collectPaths(menus: MenuConfig[]): Set<string> {
294
- const paths = new Set<string>();
252
+ // 3) 删除差集(DB - 配置,仅 state>=0) + 删除重复 path 的多余记录 + 删除禁用菜单(不分 state)
253
+ const delIdSet = new Set<number>();
295
254
 
296
- function collect(items: MenuConfig[]): void {
297
- for (const menu of items) {
298
- if (menu.path) {
299
- paths.add(menu.path);
300
- }
301
- if (menu.children && menu.children.length > 0) {
302
- collect(menu.children);
303
- }
304
- }
305
- }
306
-
307
- collect(menus);
308
- return paths;
309
- }
255
+ // 3.1) 清理禁用菜单:只要命中 disableMenus,就强制删除(避免 menu/list 之类接口还能查到)
256
+ if (disableRules.length > 0) {
257
+ for (const record of existingListAllState) {
258
+ const recordPath = typeof record?.path === "string" ? String(record.path).trim() : "";
259
+ if (!recordPath) {
260
+ continue;
261
+ }
310
262
 
311
- /**
312
- * 递归同步单个菜单(无层级限制)
313
- */
314
- async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, existingMenuMap: Map<string, any>): Promise<number> {
315
- const existing = existingMenuMap.get(menu.path || "");
316
- let menuId: number;
317
-
318
- if (existing) {
319
- menuId = existing.id;
320
-
321
- const needUpdate = existing.pid !== pid || existing.name !== menu.name || existing.sort !== (menu.sort || 1);
322
-
323
- if (needUpdate) {
324
- await helper.updData({
325
- table: "addon_admin_menu",
326
- where: { id: existing.id },
327
- data: {
328
- pid: pid,
329
- name: menu.name,
330
- sort: menu.sort || 1
263
+ if (isMenuPathDisabledByGlobRules(recordPath, disableRules)) {
264
+ if (typeof record?.id === "number" && record.id > 0) {
265
+ delIdSet.add(record.id);
266
+ }
331
267
  }
332
- });
333
- }
334
- } else {
335
- menuId = await helper.insData({
336
- table: "addon_admin_menu",
337
- data: {
338
- pid: pid,
339
- name: menu.name,
340
- path: menu.path || "",
341
- sort: menu.sort || 1
342
268
  }
343
- });
344
- }
345
-
346
- if (menu.children && menu.children.length > 0) {
347
- for (const child of menu.children) {
348
- await syncMenuRecursive(helper, child, menuId, existingMenuMap);
349
- }
350
- }
351
-
352
- return menuId;
353
- }
354
-
355
- /**
356
- * 同步菜单到数据库
357
- */
358
- async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
359
- const allExistingMenus = await helper.getAll({
360
- table: "addon_admin_menu"
361
- });
362
- const existingMenuMap = new Map<string, any>();
363
- for (const menu of allExistingMenus.lists) {
364
- if (menu.path) {
365
- existingMenuMap.set(menu.path, menu);
366
- }
367
- }
368
-
369
- for (const menu of menus) {
370
- try {
371
- await syncMenuRecursive(helper, menu, 0, existingMenuMap);
372
- } catch (error: any) {
373
- Logger.error({ err: error, menu: menu.name }, "同步菜单失败");
374
- throw error;
375
- }
376
- }
377
- }
378
-
379
- /**
380
- * 删除配置中不存在的菜单(强制删除)
381
- */
382
- async function deleteObsoleteRecords(helper: any, configPaths: Set<string>): Promise<void> {
383
- const allRecords = await helper.getAll({
384
- table: "addon_admin_menu",
385
- fields: ["id", "path"],
386
- where: { state$gte: 0 }
387
- });
388
-
389
- for (const record of allRecords.lists) {
390
- if (record.path && !configPaths.has(record.path)) {
391
- await helper.delForce({
392
- table: "addon_admin_menu",
393
- where: { id: record.id }
394
- });
395
269
  }
396
- }
397
- }
398
270
 
399
- /**
400
- * 加载所有菜单配置(addon views + 项目 menus.json)
401
- */
402
- async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: string }>> {
403
- const allMenus: Array<{ menus: MenuConfig[]; source: string }> = [];
404
-
405
- // 1. 扫描所有 addon 的 views 目录
406
- const addonNames = scanAddons();
407
-
408
- for (const addonName of addonNames) {
409
- try {
410
- const addonDir = getAddonDir(addonName, "");
411
- const viewsDir = join(addonDir, "views");
412
-
413
- if (existsSync(viewsDir)) {
414
- const prefix = `/addon/${addonName}`;
415
- const menus = await scanViewsDir(viewsDir, prefix);
416
- if (menus.length > 0) {
417
- allMenus.push({
418
- menus: menus,
419
- source: `addon:${addonName}`
420
- });
271
+ for (const record of existingList) {
272
+ if (typeof record?.path !== "string" || !record.path) {
273
+ continue;
274
+ }
275
+ if (!configPaths.has(record.path)) {
276
+ if (typeof record?.id === "number") {
277
+ delIdSet.add(record.id);
421
278
  }
422
279
  }
423
- } catch (error: any) {
424
- Logger.warn({ err: error, addon: addonName }, "扫描 addon views 目录失败");
425
280
  }
426
- }
427
281
 
428
- // 2. 读取项目的 menus.json
429
- const menusJsonPath = join(projectDir, "menus.json");
430
- if (existsSync(menusJsonPath)) {
431
- try {
432
- const content = await readFile(menusJsonPath, "utf-8");
433
- const projectMenus = JSON.parse(content);
434
- if (Array.isArray(projectMenus) && projectMenus.length > 0) {
435
- allMenus.push({
436
- menus: projectMenus,
437
- source: "project"
438
- });
282
+ for (const id of duplicateIdSet) {
283
+ if (typeof id === "number" && id > 0) {
284
+ delIdSet.add(id);
439
285
  }
440
- } catch (error: any) {
441
- Logger.warn({ err: error }, "读取项目 menus.json 失败");
442
286
  }
443
- }
444
-
445
- return allMenus;
446
- }
447
-
448
- /**
449
- * SyncMenu 命令主函数
450
- */
451
- export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<void> {
452
- try {
453
- if (options.plan) {
454
- Logger.debug("[计划] 同步菜单配置到数据库(plan 模式不执行)");
455
- return;
456
- }
457
-
458
- // 1. 加载所有菜单配置
459
- const allMenus = await loadMenuConfigs();
460
-
461
- // 2. 合并菜单配置
462
- let mergedMenus = mergeMenuConfigs(allMenus);
463
287
 
464
- // 2.1 规范化并去重(防止尾随 / 或多 / 导致隐藏菜单与 DB 同步异常)
465
- mergedMenus = normalizeMenuTree(mergedMenus);
288
+ const delIds = Array.from(delIdSet);
466
289
 
467
- // 3. 过滤隐藏菜单(根据 hiddenMenus 配置)
468
- const hiddenMenus = (beflyConfig as any).hiddenMenus || [];
469
- if (Array.isArray(hiddenMenus) && hiddenMenus.length > 0) {
470
- const hiddenSet = new Set(hiddenMenus.map((item: string) => normalizeMenuPath(item)));
471
- mergedMenus = filterHiddenMenus(mergedMenus, hiddenSet);
472
- mergedMenus = normalizeMenuTree(mergedMenus);
290
+ if (delIds.length > 0) {
291
+ await trans.delForceBatch(tableName, delIds);
473
292
  }
293
+ });
474
294
 
475
- // 连接数据库
476
- await Connect.connect();
477
-
478
- const helper = new DbHelper({ redis: new RedisHelper() } as any, Connect.getSql());
479
-
480
- // 3. 检查表是否存在
481
- const exists = await helper.tableExists("addon_admin_menu");
482
-
483
- if (!exists) {
484
- Logger.debug("表 addon_admin_menu 不存在,跳过菜单同步");
485
- return;
486
- }
487
-
488
- // 4. 收集所有菜单的 path
489
- const configPaths = collectPaths(mergedMenus);
490
-
491
- // 5. 同步菜单
492
- await syncMenus(helper, mergedMenus);
493
-
494
- // 6. 删除不存在的菜单
495
- await deleteObsoleteRecords(helper, configPaths);
496
-
497
- // 7. 获取最终菜单数据(用于缓存)
498
- const allMenusData = await helper.getAll({
499
- table: "addon_admin_menu"
500
- });
501
-
502
- // 8. 缓存菜单数据到 Redis
503
- try {
504
- const redisHelper = new RedisHelper();
505
- await redisHelper.setObject(CacheKeys.menusAll(), allMenusData.lists);
506
- } catch (error: any) {
507
- Logger.warn({ err: error }, "Redis 缓存菜单数据失败");
508
- }
509
- } catch (error: any) {
510
- Logger.error({ err: error }, "菜单同步失败");
511
- throw error;
512
- } finally {
513
- await Connect.disconnect();
514
- }
295
+ // 缓存同步职责已收敛到 syncCache(启动流程单点调用),此处只负责 DB 同步。
515
296
  }
516
297
 
517
298
  // 仅测试用(避免将内部扫描逻辑变成稳定 API)
518
299
  export const __test__ = {
519
- scanViewsDir: scanViewsDir,
520
- normalizeMenuPath: normalizeMenuPath
300
+ scanViewsDir: async (viewsDir: string, prefix: string, parentPath: string = "") => {
301
+ const mod = await import("../utils/loadMenuConfigs.js");
302
+ return await mod.scanViewsDirToMenuConfigs(viewsDir, prefix, parentPath);
303
+ },
304
+ flattenMenusToDefMap: (mergedMenus: MenuConfig[]) => {
305
+ return flattenMenusToDefMap(mergedMenus);
306
+ }
521
307
  };