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/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
 
@@ -220,7 +220,7 @@ export const beflyConfig = {
220
220
  redis: {
221
221
  host: "127.0.0.1",
222
222
  port: 6379,
223
- prefix: "befly:"
223
+ prefix: "befly"
224
224
  },
225
225
 
226
226
  // CORS 跨域配置
@@ -238,21 +238,54 @@ export const beflyConfig = {
238
238
  };
239
239
  ```
240
240
 
241
+ > 注意:`redis.prefix` 不要包含 `:`(系统会自动拼接分隔符)。
242
+
241
243
  ### 数据库连接
242
244
 
243
- 框架会自动从 `beflyConfig` 获取配置并建立连接,无需手动传参:
245
+ 通常你不需要手动连接(框架启动期会完成连接并注入插件实例)。
246
+
247
+ 如果你在自定义脚本/测试中需要手动连接,请显式传入配置片段(不要依赖全局单例配置):
244
248
 
245
249
  ```typescript
246
250
  import { Connect } from "befly/lib/connect";
247
251
 
248
- // 连接 SQL 数据库(配置自动从 beflyConfig.db 获取)
249
- await Connect.connectSql();
252
+ // 连接 SQL 数据库
253
+ await Connect.connectSql({
254
+ type: "mysql",
255
+ host: "127.0.0.1",
256
+ port: 3306,
257
+ username: "root",
258
+ password: "root",
259
+ database: "befly_demo",
260
+ poolMax: 1
261
+ });
250
262
 
251
- // 连接 Redis(配置自动从 beflyConfig.redis 获取)
252
- await Connect.connectRedis();
263
+ // 连接 Redis
264
+ await Connect.connectRedis({
265
+ host: "127.0.0.1",
266
+ port: 6379,
267
+ db: 0,
268
+ prefix: "befly"
269
+ });
253
270
 
254
- // 同时连接 SQL 和 Redis
255
- await Connect.connect();
271
+ // 或:同时连接 SQL 和 Redis
272
+ await Connect.connect({
273
+ db: {
274
+ type: "mysql",
275
+ host: "127.0.0.1",
276
+ port: 3306,
277
+ username: "root",
278
+ password: "root",
279
+ database: "befly_demo",
280
+ poolMax: 1
281
+ },
282
+ redis: {
283
+ host: "127.0.0.1",
284
+ port: 6379,
285
+ db: 0,
286
+ prefix: "befly"
287
+ }
288
+ });
256
289
 
257
290
  // 获取连接状态
258
291
  const status = Connect.getStatus();
@@ -263,19 +296,14 @@ console.log(status.redis.connected); // true/false
263
296
  await Connect.disconnect();
264
297
  ```
265
298
 
266
- ### 配置文件迁移指南
299
+ ### 配置文件(当前约定)
267
300
 
268
- 如果你的项目之前使用 `app.config.ts`,请按以下步骤迁移:
269
-
270
- 1. **重命名文件**:`app.config.ts` → `befly.config.ts`
271
- 2. **更新导出名**:`config` → `beflyConfig`
301
+ 配置文件名为 `befly.config.ts`,导出名为 `beflyConfig`:
272
302
 
273
303
  ```typescript
274
- // 旧写法
275
- export const config = { ... };
276
-
277
- // 新写法
278
- export const beflyConfig = { ... };
304
+ export const beflyConfig = {
305
+ // ...
306
+ };
279
307
  ```
280
308
 
281
309
  ## 📖 文档
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
+ }