befly 3.9.39 → 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 +4 -5
- 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/README.md
CHANGED
|
@@ -165,7 +165,7 @@ await befly.db.delData({
|
|
|
165
165
|
|
|
166
166
|
同步到数据库:
|
|
167
167
|
|
|
168
|
-
请参考:`docs/reference/sync.md`(`
|
|
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
|
-
|
|
243
|
+
通常你不需要手动连接(框架启动期会完成连接并注入插件实例)。
|
|
244
|
+
|
|
245
|
+
如果你在自定义脚本/测试中需要手动连接,请显式传入配置片段(不要依赖全局单例配置):
|
|
244
246
|
|
|
245
247
|
```typescript
|
|
246
248
|
import { Connect } from "befly/lib/connect";
|
|
247
249
|
|
|
248
|
-
// 连接 SQL
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
package/checks/checkApi.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
|
|
17
|
+
if (typeof api?.handler !== "function") {
|
|
18
|
+
Logger.warn(omit(api, ["handler"]), "接口的 handler 属性必须是函数");
|
|
19
|
+
hasError = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
52
22
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
60
|
-
if (
|
|
61
|
-
Logger.warn(
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|