befly 3.9.40 → 3.10.0
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.
- package/README.md +39 -8
- package/befly.config.ts +19 -2
- package/checks/checkApi.ts +79 -77
- package/checks/checkHook.ts +48 -0
- package/checks/checkMenu.ts +168 -0
- package/checks/checkPlugin.ts +48 -0
- package/checks/checkTable.ts +137 -183
- package/docs/README.md +1 -1
- package/docs/api/api.md +1 -1
- package/docs/guide/quickstart.md +16 -9
- package/docs/hooks/hook.md +2 -2
- package/docs/hooks/rateLimit.md +1 -1
- package/docs/infra/redis.md +7 -7
- package/docs/plugins/plugin.md +23 -21
- package/docs/quickstart.md +16 -9
- package/docs/reference/addon.md +12 -1
- package/docs/reference/config.md +13 -30
- package/docs/reference/sync.md +62 -193
- package/docs/reference/table.md +27 -29
- package/hooks/auth.ts +3 -4
- package/hooks/cors.ts +4 -6
- package/hooks/parser.ts +3 -4
- package/hooks/permission.ts +3 -4
- package/hooks/validator.ts +3 -4
- package/lib/cacheHelper.ts +89 -153
- package/lib/cacheKeys.ts +1 -1
- package/lib/connect.ts +9 -13
- package/lib/dbDialect.ts +285 -0
- package/lib/dbHelper.ts +179 -507
- package/lib/dbUtils.ts +450 -0
- package/lib/logger.ts +41 -5
- package/lib/redisHelper.ts +1 -0
- package/lib/sqlBuilder.ts +358 -58
- package/lib/sqlCheck.ts +136 -0
- package/lib/validator.ts +1 -1
- package/loader/loadApis.ts +23 -126
- package/loader/loadHooks.ts +31 -46
- package/loader/loadPlugins.ts +37 -52
- package/main.ts +58 -19
- package/package.json +24 -25
- package/paths.ts +14 -14
- package/plugins/cache.ts +12 -6
- package/plugins/cipher.ts +2 -2
- package/plugins/config.ts +6 -8
- package/plugins/db.ts +14 -19
- package/plugins/jwt.ts +6 -7
- package/plugins/logger.ts +7 -9
- package/plugins/redis.ts +8 -10
- package/plugins/tool.ts +3 -4
- package/router/api.ts +3 -2
- package/router/static.ts +7 -5
- package/sync/syncApi.ts +80 -235
- package/sync/syncCache.ts +16 -0
- package/sync/syncDev.ts +167 -202
- package/sync/syncMenu.ts +230 -444
- package/sync/syncTable.ts +1247 -0
- package/tests/_mocks/mockSqliteDb.ts +204 -0
- package/tests/addonHelper-cache.test.ts +32 -0
- package/tests/apiHandler-routePath-only.test.ts +32 -0
- package/tests/cacheHelper.test.ts +16 -51
- package/tests/checkApi-routePath-strict.test.ts +166 -0
- package/tests/checkMenu.test.ts +346 -0
- package/tests/checkTable-smoke.test.ts +157 -0
- package/tests/dbDialect-cache.test.ts +23 -0
- package/tests/dbDialect.test.ts +46 -0
- package/tests/dbHelper-advanced.test.ts +1 -1
- package/tests/dbHelper-all-array-types.test.ts +15 -15
- package/tests/dbHelper-batch-write.test.ts +90 -0
- package/tests/dbHelper-columns.test.ts +36 -54
- package/tests/dbHelper-execute.test.ts +26 -26
- package/tests/dbHelper-joins.test.ts +85 -176
- package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
- package/tests/fixtures/scanFilesApis/a.ts +3 -0
- package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
- package/tests/loadPlugins-order-smoke.test.ts +75 -0
- package/tests/logger.test.ts +6 -6
- package/tests/redisHelper.test.ts +6 -1
- package/tests/scanFiles-routePath.test.ts +46 -0
- package/tests/smoke-sql.test.ts +24 -0
- package/tests/sqlBuilder-advanced.test.ts +18 -5
- package/tests/sqlBuilder.test.ts +24 -0
- package/tests/sync-init-guard.test.ts +105 -0
- package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
- package/tests/syncApi-obsolete-records.test.ts +69 -0
- package/tests/syncApi-type-compat.test.ts +72 -0
- package/tests/syncDev-permissions.test.ts +81 -0
- package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
- package/tests/syncMenu-duplicate-path.test.ts +122 -0
- package/tests/syncMenu-obsolete-records.test.ts +161 -0
- package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
- package/tests/syncMenu-paths.test.ts +0 -9
- package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
- package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
- package/tests/syncTable-constants.test.ts +101 -0
- package/tests/syncTable-db-integration.test.ts +237 -0
- package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
- package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
- package/tests/syncTable-schema.test.ts +99 -0
- package/tests/syncTable-testkit.test.ts +25 -0
- package/tests/syncTable-types.test.ts +122 -0
- package/tests/tableRef-and-deserialize.test.ts +67 -0
- package/tsconfig.json +1 -1
- package/types/api.d.ts +1 -1
- package/types/befly.d.ts +13 -12
- package/types/cache.d.ts +2 -2
- package/types/context.d.ts +1 -1
- package/types/database.d.ts +0 -5
- package/types/hook.d.ts +1 -10
- package/types/plugin.d.ts +2 -96
- package/types/sync.d.ts +19 -25
- package/utils/convertBigIntFields.ts +38 -0
- package/utils/disableMenusGlob.ts +85 -0
- package/utils/importDefault.ts +21 -0
- package/utils/isDirentDirectory.ts +23 -0
- package/utils/loadMenuConfigs.ts +145 -0
- package/utils/processFields.ts +25 -0
- package/utils/scanAddons.ts +72 -0
- package/utils/scanFiles.ts +129 -21
- package/utils/scanSources.ts +64 -0
- package/utils/sortModules.ts +137 -0
- package/checks/checkApp.ts +0 -55
- package/hooks/rateLimit.ts +0 -276
- package/sync/syncAll.ts +0 -35
- package/sync/syncDb/apply.ts +0 -192
- package/sync/syncDb/constants.ts +0 -119
- package/sync/syncDb/ddl.ts +0 -251
- package/sync/syncDb/helpers.ts +0 -84
- package/sync/syncDb/schema.ts +0 -202
- package/sync/syncDb/sqlite.ts +0 -48
- package/sync/syncDb/table.ts +0 -207
- package/sync/syncDb/tableCreate.ts +0 -163
- package/sync/syncDb/types.ts +0 -132
- package/sync/syncDb/version.ts +0 -69
- package/sync/syncDb.ts +0 -168
- package/tests/rateLimit-hook.test.ts +0 -477
- package/tests/syncDb-constants.test.ts +0 -130
- package/tests/syncDb-schema.test.ts +0 -179
- package/tests/syncDb-types.test.ts +0 -139
- package/utils/addonHelper.ts +0 -90
- package/utils/modules.ts +0 -98
- package/utils/route.ts +0 -23
package/sync/syncMenu.ts
CHANGED
|
@@ -1,521 +1,307 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
//
|
|
47
|
-
// -
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 (!
|
|
86
|
-
// path 缺失的菜单无法参与同步/去重,直接丢弃
|
|
46
|
+
if (!path) {
|
|
87
47
|
continue;
|
|
88
48
|
}
|
|
89
49
|
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
60
|
+
menuDefMap.set(path, {
|
|
61
|
+
path: path,
|
|
62
|
+
name: name,
|
|
63
|
+
sort: sort,
|
|
64
|
+
parentPath: parentPath
|
|
65
|
+
});
|
|
124
66
|
}
|
|
125
67
|
|
|
126
|
-
return
|
|
68
|
+
return menuDefMap;
|
|
127
69
|
}
|
|
128
70
|
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
return null;
|
|
76
|
+
if (!ctx.cache) {
|
|
77
|
+
throw new Error("syncMenu: ctx.cache 未初始化(cache 插件未加载或注入失败)");
|
|
139
78
|
}
|
|
140
79
|
|
|
141
|
-
|
|
80
|
+
if (!ctx.config) {
|
|
81
|
+
throw new Error("syncMenu: ctx.config 未初始化(config 插件未加载或注入失败)");
|
|
82
|
+
}
|
|
142
83
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
}
|
|
84
|
+
if (!(await ctx.db.tableExists("addon_admin_menu"))) {
|
|
85
|
+
Logger.debug(`addon_admin_menu 表不存在`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
148
88
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
const entries = await readdir(viewsDir, { withFileTypes: true });
|
|
130
|
+
const tableName = "addon_admin_menu";
|
|
163
131
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
171
|
-
const
|
|
140
|
+
const existingListAllState = allExistingMenusAllState.lists || [];
|
|
141
|
+
const existingList = existingListAllState.filter((m: any) => typeof m?.state === "number" && m.state >= 0);
|
|
172
142
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
190
|
-
if (!
|
|
191
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
161
|
+
const existingId = typeof existing?.id === "number" ? existing.id : 0;
|
|
162
|
+
const recordId = record.id;
|
|
203
163
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
215
|
-
|
|
168
|
+
if (existingId > 0) {
|
|
169
|
+
duplicateIdSet.add(existingId);
|
|
170
|
+
}
|
|
216
171
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
218
|
+
for (const def of menuDefMap.values()) {
|
|
219
|
+
const existing = existingMenuMap.get(def.path);
|
|
250
220
|
if (existing) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
465
|
-
mergedMenus = normalizeMenuTree(mergedMenus);
|
|
288
|
+
const delIds = Array.from(delIdSet);
|
|
466
289
|
|
|
467
|
-
|
|
468
|
-
|
|
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:
|
|
520
|
-
|
|
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
|
};
|