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.
Files changed (141) hide show
  1. package/README.md +39 -8
  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 +1 -1
  9. package/docs/api/api.md +1 -1
  10. package/docs/guide/quickstart.md +16 -9
  11. package/docs/hooks/hook.md +2 -2
  12. package/docs/hooks/rateLimit.md +1 -1
  13. package/docs/infra/redis.md +7 -7
  14. package/docs/plugins/plugin.md +23 -21
  15. package/docs/quickstart.md +16 -9
  16. package/docs/reference/addon.md +12 -1
  17. package/docs/reference/config.md +13 -30
  18. package/docs/reference/sync.md +62 -193
  19. package/docs/reference/table.md +27 -29
  20. package/hooks/auth.ts +3 -4
  21. package/hooks/cors.ts +4 -6
  22. package/hooks/parser.ts +3 -4
  23. package/hooks/permission.ts +3 -4
  24. package/hooks/validator.ts +3 -4
  25. package/lib/cacheHelper.ts +89 -153
  26. package/lib/cacheKeys.ts +1 -1
  27. package/lib/connect.ts +9 -13
  28. package/lib/dbDialect.ts +285 -0
  29. package/lib/dbHelper.ts +179 -507
  30. package/lib/dbUtils.ts +450 -0
  31. package/lib/logger.ts +41 -5
  32. package/lib/redisHelper.ts +1 -0
  33. package/lib/sqlBuilder.ts +358 -58
  34. package/lib/sqlCheck.ts +136 -0
  35. package/lib/validator.ts +1 -1
  36. package/loader/loadApis.ts +23 -126
  37. package/loader/loadHooks.ts +31 -46
  38. package/loader/loadPlugins.ts +37 -52
  39. package/main.ts +58 -19
  40. package/package.json +24 -25
  41. package/paths.ts +14 -14
  42. package/plugins/cache.ts +12 -6
  43. package/plugins/cipher.ts +2 -2
  44. package/plugins/config.ts +6 -8
  45. package/plugins/db.ts +14 -19
  46. package/plugins/jwt.ts +6 -7
  47. package/plugins/logger.ts +7 -9
  48. package/plugins/redis.ts +8 -10
  49. package/plugins/tool.ts +3 -4
  50. package/router/api.ts +3 -2
  51. package/router/static.ts +7 -5
  52. package/sync/syncApi.ts +80 -235
  53. package/sync/syncCache.ts +16 -0
  54. package/sync/syncDev.ts +167 -202
  55. package/sync/syncMenu.ts +230 -444
  56. package/sync/syncTable.ts +1247 -0
  57. package/tests/_mocks/mockSqliteDb.ts +204 -0
  58. package/tests/addonHelper-cache.test.ts +32 -0
  59. package/tests/apiHandler-routePath-only.test.ts +32 -0
  60. package/tests/cacheHelper.test.ts +16 -51
  61. package/tests/checkApi-routePath-strict.test.ts +166 -0
  62. package/tests/checkMenu.test.ts +346 -0
  63. package/tests/checkTable-smoke.test.ts +157 -0
  64. package/tests/dbDialect-cache.test.ts +23 -0
  65. package/tests/dbDialect.test.ts +46 -0
  66. package/tests/dbHelper-advanced.test.ts +1 -1
  67. package/tests/dbHelper-all-array-types.test.ts +15 -15
  68. package/tests/dbHelper-batch-write.test.ts +90 -0
  69. package/tests/dbHelper-columns.test.ts +36 -54
  70. package/tests/dbHelper-execute.test.ts +26 -26
  71. package/tests/dbHelper-joins.test.ts +85 -176
  72. package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
  73. package/tests/fixtures/scanFilesApis/a.ts +3 -0
  74. package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
  75. package/tests/loadPlugins-order-smoke.test.ts +75 -0
  76. package/tests/logger.test.ts +6 -6
  77. package/tests/redisHelper.test.ts +6 -1
  78. package/tests/scanFiles-routePath.test.ts +46 -0
  79. package/tests/smoke-sql.test.ts +24 -0
  80. package/tests/sqlBuilder-advanced.test.ts +18 -5
  81. package/tests/sqlBuilder.test.ts +24 -0
  82. package/tests/sync-init-guard.test.ts +105 -0
  83. package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
  84. package/tests/syncApi-obsolete-records.test.ts +69 -0
  85. package/tests/syncApi-type-compat.test.ts +72 -0
  86. package/tests/syncDev-permissions.test.ts +81 -0
  87. package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
  88. package/tests/syncMenu-duplicate-path.test.ts +122 -0
  89. package/tests/syncMenu-obsolete-records.test.ts +161 -0
  90. package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
  91. package/tests/syncMenu-paths.test.ts +0 -9
  92. package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
  93. package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
  94. package/tests/syncTable-constants.test.ts +101 -0
  95. package/tests/syncTable-db-integration.test.ts +237 -0
  96. package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
  97. package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
  98. package/tests/syncTable-schema.test.ts +99 -0
  99. package/tests/syncTable-testkit.test.ts +25 -0
  100. package/tests/syncTable-types.test.ts +122 -0
  101. package/tests/tableRef-and-deserialize.test.ts +67 -0
  102. package/tsconfig.json +1 -1
  103. package/types/api.d.ts +1 -1
  104. package/types/befly.d.ts +13 -12
  105. package/types/cache.d.ts +2 -2
  106. package/types/context.d.ts +1 -1
  107. package/types/database.d.ts +0 -5
  108. package/types/hook.d.ts +1 -10
  109. package/types/plugin.d.ts +2 -96
  110. package/types/sync.d.ts +19 -25
  111. package/utils/convertBigIntFields.ts +38 -0
  112. package/utils/disableMenusGlob.ts +85 -0
  113. package/utils/importDefault.ts +21 -0
  114. package/utils/isDirentDirectory.ts +23 -0
  115. package/utils/loadMenuConfigs.ts +145 -0
  116. package/utils/processFields.ts +25 -0
  117. package/utils/scanAddons.ts +72 -0
  118. package/utils/scanFiles.ts +129 -21
  119. package/utils/scanSources.ts +64 -0
  120. package/utils/sortModules.ts +137 -0
  121. package/checks/checkApp.ts +0 -55
  122. package/hooks/rateLimit.ts +0 -276
  123. package/sync/syncAll.ts +0 -35
  124. package/sync/syncDb/apply.ts +0 -192
  125. package/sync/syncDb/constants.ts +0 -119
  126. package/sync/syncDb/ddl.ts +0 -251
  127. package/sync/syncDb/helpers.ts +0 -84
  128. package/sync/syncDb/schema.ts +0 -202
  129. package/sync/syncDb/sqlite.ts +0 -48
  130. package/sync/syncDb/table.ts +0 -207
  131. package/sync/syncDb/tableCreate.ts +0 -163
  132. package/sync/syncDb/types.ts +0 -132
  133. package/sync/syncDb/version.ts +0 -69
  134. package/sync/syncDb.ts +0 -168
  135. package/tests/rateLimit-hook.test.ts +0 -477
  136. package/tests/syncDb-constants.test.ts +0 -130
  137. package/tests/syncDb-schema.test.ts +0 -179
  138. package/tests/syncDb-types.test.ts +0 -139
  139. package/utils/addonHelper.ts +0 -90
  140. package/utils/modules.ts +0 -98
  141. package/utils/route.ts +0 -23
package/README.md CHANGED
@@ -165,7 +165,7 @@ await befly.db.delData({
165
165
 
166
166
  同步到数据库:
167
167
 
168
- 请参考:`docs/reference/sync.md`(`syncDb` / `syncAll` 等同步流程说明)。
168
+ 请参考:`docs/reference/sync.md`(`syncTable` / `syncData` 等同步流程说明)。
169
169
 
170
170
  ## 🗄️ 数据库配置
171
171
 
@@ -240,19 +240,50 @@ export const beflyConfig = {
240
240
 
241
241
  ### 数据库连接
242
242
 
243
- 框架会自动从 `beflyConfig` 获取配置并建立连接,无需手动传参:
243
+ 通常你不需要手动连接(框架启动期会完成连接并注入插件实例)。
244
+
245
+ 如果你在自定义脚本/测试中需要手动连接,请显式传入配置片段(不要依赖全局单例配置):
244
246
 
245
247
  ```typescript
246
248
  import { Connect } from "befly/lib/connect";
247
249
 
248
- // 连接 SQL 数据库(配置自动从 beflyConfig.db 获取)
249
- await Connect.connectSql();
250
+ // 连接 SQL 数据库
251
+ await Connect.connectSql({
252
+ type: "mysql",
253
+ host: "127.0.0.1",
254
+ port: 3306,
255
+ username: "root",
256
+ password: "root",
257
+ database: "befly_demo",
258
+ poolMax: 1
259
+ });
250
260
 
251
- // 连接 Redis(配置自动从 beflyConfig.redis 获取)
252
- await Connect.connectRedis();
261
+ // 连接 Redis
262
+ await Connect.connectRedis({
263
+ host: "127.0.0.1",
264
+ port: 6379,
265
+ db: 0,
266
+ prefix: "befly:"
267
+ });
253
268
 
254
- // 同时连接 SQL 和 Redis
255
- await Connect.connect();
269
+ // 或:同时连接 SQL 和 Redis
270
+ await Connect.connect({
271
+ db: {
272
+ type: "mysql",
273
+ host: "127.0.0.1",
274
+ port: 3306,
275
+ username: "root",
276
+ password: "root",
277
+ database: "befly_demo",
278
+ poolMax: 1
279
+ },
280
+ redis: {
281
+ host: "127.0.0.1",
282
+ port: 6379,
283
+ db: 0,
284
+ prefix: "befly:"
285
+ }
286
+ });
256
287
 
257
288
  // 获取连接状态
258
289
  const status = Connect.getStatus();
package/befly.config.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { BeflyOptions } from "./types/befly.js";
7
7
 
8
+ import { compileDisableMenuGlobRules } from "./utils/disableMenusGlob.js";
8
9
  import { scanConfig } from "./utils/scanConfig.js";
9
10
 
10
11
  /** 默认配置 */
@@ -46,7 +47,7 @@ const defaultOptions: BeflyOptions = {
46
47
  username: "",
47
48
  password: "",
48
49
  db: 0,
49
- prefix: "befly_demo:"
50
+ prefix: "befly_demo"
50
51
  },
51
52
 
52
53
  // ========== 认证配置 ==========
@@ -80,7 +81,7 @@ const defaultOptions: BeflyOptions = {
80
81
  // ========== 禁用配置 ==========
81
82
  disableHooks: [],
82
83
  disablePlugins: [],
83
- hiddenMenus: [],
84
+ disableMenus: ["**/404", "**/403", "**/500", "**/login"],
84
85
 
85
86
  // ========== Addon 配置 ==========
86
87
  addons: {}
@@ -106,6 +107,22 @@ export async function loadBeflyConfig(options: LoadBeflyConfigOptions = {}): Pro
106
107
  defaults: defaultOptions
107
108
  });
108
109
 
110
+ // 配置校验:redis.prefix 作为 key 前缀,由 RedisHelper 统一拼接 ":"。
111
+ // 因此 prefix 本身不允许包含 ":",否则会导致 key 结构出现空段或多段分隔(例如 "prefix::key"),
112
+ // 在 RedisInsight 等工具里可能显示 [NO NAME] 空分组,且容易造成 key 管理混乱。
113
+ const redisPrefix = (config as any)?.redis?.prefix;
114
+ if (typeof redisPrefix === "string") {
115
+ const trimmedPrefix = redisPrefix.trim();
116
+ if (trimmedPrefix.includes(":")) {
117
+ throw new Error(`配置错误:redis.prefix 不允许包含 ':'(RedisHelper 会自动拼接分隔符 ':'),请改为不带冒号的前缀,例如 'befly_demo',当前值=${redisPrefix}`);
118
+ }
119
+ }
120
+
121
+ // 预编译 disableMenus 的 Bun.Glob 规则:
122
+ // - 提前暴露配置错误(fail-fast)
123
+ // - 后续 checkMenu 会复用同一进程级缓存
124
+ compileDisableMenuGlobRules((config as any)?.disableMenus);
125
+
109
126
  return config as BeflyOptions;
110
127
  }
111
128
 
@@ -1,94 +1,96 @@
1
- // 内部依赖
2
- import { existsSync } from "node:fs";
3
-
4
- // 外部依赖
5
1
  import { isPlainObject } from "es-toolkit/compat";
2
+ import { omit } from "es-toolkit/object";
6
3
 
7
4
  import { Logger } from "../lib/logger.js";
8
- import { projectApiDir } from "../paths.js";
9
- import { scanAddons, getAddonDir, addonDirExists } from "../utils/addonHelper.js";
10
- // 相对导入
11
- import { scanFiles } from "../utils/scanFiles.js";
12
-
13
- /**
14
- * 检查所有 API 定义
15
- */
16
- export async function checkApi(): Promise<void> {
17
- try {
18
- // 收集所有 API 文件
19
- const allApiFiles: Array<{ file: string; displayName: string; apiPath: string }> = [];
20
-
21
- // 收集项目 API 文件
22
- if (existsSync(projectApiDir)) {
23
- const files = await scanFiles(projectApiDir);
24
- for (const { filePath, relativePath } of files) {
25
- allApiFiles.push({
26
- file: filePath,
27
- displayName: "用户",
28
- apiPath: relativePath
29
- });
30
- }
31
- }
32
5
 
33
- // 收集组件 API 文件
34
- const addons = scanAddons();
35
- for (const addon of addons) {
36
- if (!addonDirExists(addon, "apis")) continue;
37
- const addonApiDir = getAddonDir(addon, "apis");
38
-
39
- const files = await scanFiles(addonApiDir);
40
- for (const { filePath, relativePath } of files) {
41
- allApiFiles.push({
42
- file: filePath,
43
- displayName: `组件${addon}`,
44
- apiPath: relativePath
45
- });
6
+ export async function checkApi(apis: any[]): Promise<void> {
7
+ let hasError = false;
8
+
9
+ for (const api of apis) {
10
+ try {
11
+ if (typeof api?.name !== "string" || api.name.trim() === "") {
12
+ Logger.warn(omit(api, ["handler"]), "接口的 name 属性必须是非空字符串");
13
+ hasError = true;
14
+ continue;
46
15
  }
47
- }
48
16
 
49
- // 合并进行验证逻辑
50
- for (const item of allApiFiles) {
51
- const { apiPath } = item;
17
+ if (typeof api?.handler !== "function") {
18
+ Logger.warn(omit(api, ["handler"]), "接口的 handler 属性必须是函数");
19
+ hasError = true;
20
+ continue;
21
+ }
52
22
 
53
- try {
54
- // Windows 下路径需要转换为正斜杠格式
55
- const filePath = item.file.replace(/\\/g, "/");
56
- const apiImport = await import(filePath);
57
- const api = apiImport.default;
23
+ // routePath / routePrefix 由 scanFiles 系统生成:必须是严格的 pathname
24
+ if (typeof api?.routePath !== "string" || api.routePath.trim() === "") {
25
+ Logger.warn(omit(api, ["handler"]), "接口的 routePath 属性必须是非空字符串(由系统生成)");
26
+ hasError = true;
27
+ } else {
28
+ const routePath = api.routePath.trim();
58
29
 
59
- // 验证必填属性:name handler
60
- if (typeof api.name !== "string" || api.name.trim() === "") {
61
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} name 属性必须是非空字符串`);
62
- continue;
63
- }
64
- if (typeof api.handler !== "function") {
65
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} 的 handler 属性必须是函数`);
66
- continue;
30
+ // 不允许出现 "POST/api/..." 等 method 前缀
31
+ if (/^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/i.test(routePath)) {
32
+ Logger.warn(omit(api, ["handler"]), "接口的 routePath 不允许包含 method 前缀,应为 url.pathname(例如 /api/app/xxx)");
33
+ hasError = true;
67
34
  }
68
35
 
69
- // 验证可选属性的类型(如果提供了)
70
- const validMethods = ["GET", "POST", "GET,POST", "POST,GET"];
71
- if (api.method && !validMethods.includes(api.method.toUpperCase())) {
72
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} 的 method 属性必须是有效的 HTTP 方法 (GET, POST, GET,POST, POST,GET)`);
36
+ if (!routePath.startsWith("/api/")) {
37
+ Logger.warn(omit(api, ["handler"]), "接口的 routePath 必须以 /api/ 开头");
38
+ hasError = true;
73
39
  }
74
- if (api.auth !== undefined && typeof api.auth !== "boolean") {
75
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} 的 auth 属性必须是布尔值 (true=需登录, false=公开)`);
76
- }
77
- if (api.fields && !isPlainObject(api.fields)) {
78
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} 的 fields 属性必须是对象`);
79
- }
80
- if (api.required && !Array.isArray(api.required)) {
81
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} 的 required 属性必须是数组`);
40
+
41
+ if (routePath.includes(" ")) {
42
+ Logger.warn(omit(api, ["handler"]), "接口的 routePath 不允许包含空格");
43
+ hasError = true;
82
44
  }
83
- if (api.required && api.required.some((item: any) => typeof item !== "string")) {
84
- Logger.warn(`[${item.displayName}] 接口 ${apiPath} 的 required 属性必须是字符串数组`);
45
+
46
+ if (routePath.includes("/api//")) {
47
+ Logger.warn(omit(api, ["handler"]), "接口的 routePath 不允许出现 /api//(重复斜杠)");
48
+ hasError = true;
85
49
  }
86
- } catch (error: any) {
87
- Logger.error(`[${item.displayName}] 接口 ${apiPath} 解析失败`, error);
88
50
  }
51
+
52
+ if (typeof api?.routePrefix !== "string" || api.routePrefix.trim() === "") {
53
+ Logger.warn(omit(api, ["handler"]), "接口的 routePrefix 属性必须是非空字符串(由系统生成)");
54
+ hasError = true;
55
+ }
56
+
57
+ if (api.method && !["GET", "POST", "GET,POST", "POST,GET"].includes(String(api.method).toUpperCase())) {
58
+ Logger.warn(omit(api, ["handler"]), "接口的 method 属性必须是有效的 HTTP 方法 (GET, POST, GET,POST, POST,GET)");
59
+ hasError = true;
60
+ }
61
+
62
+ if (api.auth !== undefined && typeof api.auth !== "boolean") {
63
+ Logger.warn(omit(api, ["handler"]), "接口的 auth 属性必须是布尔值 (true=需登录, false=公开)");
64
+ hasError = true;
65
+ }
66
+
67
+ if (api.fields && !isPlainObject(api.fields)) {
68
+ Logger.warn(omit(api, ["handler"]), "接口的 fields 属性必须是对象");
69
+ hasError = true;
70
+ }
71
+
72
+ if (api.required && !Array.isArray(api.required)) {
73
+ Logger.warn(omit(api, ["handler"]), "接口的 required 属性必须是数组");
74
+ hasError = true;
75
+ }
76
+
77
+ if (api.required && api.required.some((reqItem: any) => typeof reqItem !== "string")) {
78
+ Logger.warn(omit(api, ["handler"]), "接口的 required 属性必须是字符串数组");
79
+ hasError = true;
80
+ }
81
+ } catch (error: any) {
82
+ Logger.error(
83
+ {
84
+ err: error,
85
+ item: api
86
+ },
87
+ "接口解析失败"
88
+ );
89
+ hasError = true;
89
90
  }
90
- } catch (error: any) {
91
- Logger.error("API 定义检查过程中出错", error);
92
- throw error;
91
+ }
92
+
93
+ if (hasError) {
94
+ throw new Error("接口结构检查失败");
93
95
  }
94
96
  }
@@ -0,0 +1,48 @@
1
+ import { isPlainObject } from "es-toolkit/compat";
2
+ import { omit } from "es-toolkit/object";
3
+
4
+ import { Logger } from "../lib/logger.js";
5
+
6
+ export async function checkHook(hooks: any[]): Promise<void> {
7
+ let hasError = false;
8
+
9
+ for (const hook of hooks) {
10
+ try {
11
+ if (!isPlainObject(hook)) {
12
+ Logger.warn(omit(hook, ["handler"]), "钩子导出必须是对象(export default { deps, handler })");
13
+ hasError = true;
14
+ continue;
15
+ }
16
+
17
+ if (!Array.isArray((hook as any).deps)) {
18
+ Logger.warn(omit(hook, ["handler"]), "钩子的 deps 属性必须是字符串数组");
19
+ hasError = true;
20
+ continue;
21
+ }
22
+
23
+ if ((hook as any).deps.some((depItem: any) => typeof depItem !== "string")) {
24
+ Logger.warn(omit(hook, ["handler"]), "钩子的 deps 属性必须是字符串数组");
25
+ hasError = true;
26
+ }
27
+
28
+ if (typeof (hook as any).handler !== "function") {
29
+ Logger.warn(omit(hook, ["handler"]), "钩子的 handler 属性必须是函数");
30
+ hasError = true;
31
+ continue;
32
+ }
33
+ } catch (error: any) {
34
+ Logger.error(
35
+ {
36
+ err: error,
37
+ item: hook
38
+ },
39
+ "钩子解析失败"
40
+ );
41
+ hasError = true;
42
+ }
43
+ }
44
+
45
+ if (hasError) {
46
+ throw new Error("钩子结构检查失败");
47
+ }
48
+ }
@@ -0,0 +1,168 @@
1
+ import type { MenuConfig } from "../types/sync.js";
2
+ import type { AddonInfo } from "../utils/scanAddons.js";
3
+
4
+ import { Logger } from "../lib/logger.js";
5
+ import { compileDisableMenuGlobRules, isMenuPathDisabledByGlobRules } from "../utils/disableMenusGlob.js";
6
+ import { loadMenuConfigs } from "../utils/loadMenuConfigs.js";
7
+
8
+ function isValidMenuPath(path: string): { ok: boolean; reason: string } {
9
+ if (!path) {
10
+ return { ok: false, reason: "path 不能为空" };
11
+ }
12
+ if (!path.startsWith("/")) {
13
+ return { ok: false, reason: "path 必须以 / 开头" };
14
+ }
15
+ if (path.length > 1 && path.endsWith("/")) {
16
+ return { ok: false, reason: "path 末尾不能是 /(根路径 / 除外)" };
17
+ }
18
+ if (path.includes("//")) {
19
+ return { ok: false, reason: "path 不能包含 //" };
20
+ }
21
+ if (path.includes("..")) {
22
+ return { ok: false, reason: "path 不能包含 .." };
23
+ }
24
+ return { ok: true, reason: "" };
25
+ }
26
+
27
+ type CheckMenuOptions = {
28
+ disableMenus?: string[];
29
+ };
30
+
31
+ type DisableMenuRule = ReturnType<typeof compileDisableMenuGlobRules>[number];
32
+
33
+ function filterMenusByDisableRules(mergedMenus: MenuConfig[], rules: DisableMenuRule[]): MenuConfig[] {
34
+ if (rules.length === 0) {
35
+ return mergedMenus;
36
+ }
37
+
38
+ const filtered: MenuConfig[] = [];
39
+
40
+ for (const menu of mergedMenus) {
41
+ const menuPath = typeof (menu as any)?.path === "string" ? String((menu as any).path).trim() : "";
42
+ if (menuPath && isMenuPathDisabledByGlobRules(menuPath, rules)) {
43
+ continue;
44
+ }
45
+
46
+ const children = Array.isArray((menu as any)?.children) ? ((menu as any).children as MenuConfig[]) : null;
47
+ if (children && children.length > 0) {
48
+ const nextChildren = filterMenusByDisableRules(children, rules);
49
+ if (nextChildren.length > 0) {
50
+ (menu as any).children = nextChildren;
51
+ } else {
52
+ delete (menu as any).children;
53
+ }
54
+ }
55
+
56
+ filtered.push(menu);
57
+ }
58
+
59
+ return filtered;
60
+ }
61
+
62
+ export const checkMenu = async (addons: AddonInfo[], options: CheckMenuOptions = {}): Promise<MenuConfig[]> => {
63
+ let hasError = false;
64
+
65
+ const mergedMenus = await loadMenuConfigs(addons);
66
+
67
+ const disableRules = compileDisableMenuGlobRules(options.disableMenus);
68
+ const filteredMenus = filterMenusByDisableRules(mergedMenus, disableRules);
69
+
70
+ const stack: Array<{ menu: any; depth: number }> = [];
71
+ for (const m of filteredMenus) {
72
+ stack.push({ menu: m, depth: 1 });
73
+ }
74
+
75
+ const pathSet = new Set<string>();
76
+
77
+ while (stack.length > 0) {
78
+ const current = stack.pop() as { menu: any; depth: number };
79
+ const menu = current?.menu;
80
+ const depth = typeof current?.depth === "number" ? current.depth : 0;
81
+
82
+ if (menu === null || typeof menu !== "object") {
83
+ hasError = true;
84
+ Logger.warn({ menu: menu }, "菜单节点必须是对象");
85
+ continue;
86
+ }
87
+
88
+ if (depth > 3) {
89
+ hasError = true;
90
+ Logger.warn({ path: menu?.path, depth: depth }, "菜单层级超过 3 级(最多三级)");
91
+ continue;
92
+ }
93
+
94
+ const children = menu.children;
95
+ if (typeof children !== "undefined" && !Array.isArray(children)) {
96
+ hasError = true;
97
+ Logger.warn({ path: menu?.path, childrenType: typeof children }, "菜单 children 必须是数组");
98
+ continue;
99
+ }
100
+
101
+ if (Array.isArray(children) && children.length > 0) {
102
+ if (depth >= 3) {
103
+ hasError = true;
104
+ Logger.warn({ path: menu?.path, depth: depth }, "菜单层级超过 3 级(最多三级)");
105
+ } else {
106
+ for (const child of children) {
107
+ stack.push({ menu: child, depth: depth + 1 });
108
+ }
109
+ }
110
+ }
111
+
112
+ const path = typeof menu.path === "string" ? menu.path.trim() : "";
113
+ const name = typeof menu.name === "string" ? menu.name.trim() : "";
114
+ const sort = typeof menu.sort === "number" ? menu.sort : 999999;
115
+
116
+ // 标准化输出(用于后续 syncMenu 直接使用)
117
+ if (typeof menu.path === "string") {
118
+ menu.path = path;
119
+ }
120
+ if (typeof menu.name === "string") {
121
+ menu.name = name;
122
+ }
123
+ if (typeof menu.sort === "undefined") {
124
+ menu.sort = sort;
125
+ }
126
+
127
+ if (!path) {
128
+ hasError = true;
129
+ Logger.warn({ menu: menu }, "菜单缺少 path(必须是非空字符串)");
130
+ continue;
131
+ }
132
+
133
+ const pathCheck = isValidMenuPath(path);
134
+ if (!pathCheck.ok) {
135
+ hasError = true;
136
+ Logger.warn({ path: path, reason: pathCheck.reason }, "菜单 path 不合法");
137
+ }
138
+
139
+ if (!name) {
140
+ hasError = true;
141
+ Logger.warn({ path: path, menu: menu }, "菜单缺少 name(必须是非空字符串)");
142
+ }
143
+
144
+ if (typeof menu.sort !== "undefined" && typeof menu.sort !== "number") {
145
+ hasError = true;
146
+ Logger.warn({ path: path, sort: menu.sort }, "菜单 sort 必须是 number");
147
+ }
148
+
149
+ if (typeof menu.sort === "number" && (!Number.isFinite(menu.sort) || menu.sort < 1)) {
150
+ hasError = true;
151
+ Logger.warn({ path: path, sort: menu.sort }, "菜单 sort 最小值为 1");
152
+ }
153
+
154
+ if (pathSet.has(path)) {
155
+ hasError = true;
156
+ Logger.warn({ path: path }, "菜单 path 重复(严格模式禁止重复 path)");
157
+ continue;
158
+ }
159
+
160
+ pathSet.add(path);
161
+ }
162
+
163
+ if (hasError) {
164
+ throw new Error("菜单结构检查失败");
165
+ }
166
+
167
+ return filteredMenus;
168
+ };
@@ -0,0 +1,48 @@
1
+ import { isPlainObject } from "es-toolkit/compat";
2
+ import { omit } from "es-toolkit/object";
3
+
4
+ import { Logger } from "../lib/logger.js";
5
+
6
+ export async function checkPlugin(plugins: any[]): Promise<void> {
7
+ let hasError = false;
8
+
9
+ for (const plugin of plugins) {
10
+ try {
11
+ if (!isPlainObject(plugin)) {
12
+ Logger.warn(omit(plugin, ["handler"]), "插件导出必须是对象(export default { deps, handler })");
13
+ hasError = true;
14
+ continue;
15
+ }
16
+
17
+ if (!Array.isArray((plugin as any).deps)) {
18
+ Logger.warn(omit(plugin, ["handler"]), "插件的 deps 属性必须是字符串数组");
19
+ hasError = true;
20
+ continue;
21
+ }
22
+
23
+ if ((plugin as any).deps.some((depItem: any) => typeof depItem !== "string")) {
24
+ Logger.warn(omit(plugin, ["handler"]), "插件的 deps 属性必须是字符串数组");
25
+ hasError = true;
26
+ }
27
+
28
+ if (typeof (plugin as any).handler !== "function") {
29
+ Logger.warn(omit(plugin, ["handler"]), "插件的 handler 属性必须是函数");
30
+ hasError = true;
31
+ continue;
32
+ }
33
+ } catch (error: any) {
34
+ Logger.error(
35
+ {
36
+ err: error,
37
+ item: plugin
38
+ },
39
+ "插件解析失败"
40
+ );
41
+ hasError = true;
42
+ }
43
+ }
44
+
45
+ if (hasError) {
46
+ throw new Error("插件结构检查失败");
47
+ }
48
+ }