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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { camelCase } from "es-toolkit/string";
|
|
2
|
+
|
|
3
|
+
import { Logger } from "../lib/logger.js";
|
|
4
|
+
|
|
5
|
+
export type SortModulesByDepsOptions<T> = {
|
|
6
|
+
/**
|
|
7
|
+
* 用于日志的模块标签(如:"插件"、"钩子")
|
|
8
|
+
*/
|
|
9
|
+
moduleLabel?: string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 生成模块名(用于 deps 解析与排序 key)。
|
|
13
|
+
* 默认:camelCase(item.fileName)
|
|
14
|
+
*/
|
|
15
|
+
getName?: (item: T) => string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 获取 deps。
|
|
19
|
+
* 默认:item.deps
|
|
20
|
+
*/
|
|
21
|
+
getDeps?: (item: T) => string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 按 deps 拓扑排序 scanSources 扫描得到的插件/钩子。
|
|
26
|
+
*
|
|
27
|
+
* 说明:
|
|
28
|
+
* - 输入为 scanSources/scanFiles 的条目数组:每个条目包含 fileName 与 deps。
|
|
29
|
+
* - deps 里的字符串会与 getName(item) 的结果匹配。
|
|
30
|
+
* - 若出现:重复 name、缺失依赖、循环依赖,则返回 false。
|
|
31
|
+
*/
|
|
32
|
+
export function sortModules<T extends { fileName: string; deps?: any }>(items: T[], options: SortModulesByDepsOptions<T> = {}): T[] | false {
|
|
33
|
+
const moduleLabel = options.moduleLabel || "模块";
|
|
34
|
+
const getName =
|
|
35
|
+
options.getName ||
|
|
36
|
+
((item: T) => {
|
|
37
|
+
const moduleName = (item as any).moduleName;
|
|
38
|
+
if (typeof moduleName === "string" && moduleName.trim() !== "") {
|
|
39
|
+
return moduleName;
|
|
40
|
+
}
|
|
41
|
+
return camelCase(item.fileName);
|
|
42
|
+
});
|
|
43
|
+
const getDeps = options.getDeps || ((item: T) => (item as any).deps);
|
|
44
|
+
|
|
45
|
+
const result: T[] = [];
|
|
46
|
+
const visited = new Set<string>();
|
|
47
|
+
const visiting = new Set<string>();
|
|
48
|
+
|
|
49
|
+
const nameToItem: Record<string, T> = {};
|
|
50
|
+
let isPass = true;
|
|
51
|
+
|
|
52
|
+
// 1) 建表 + 重名检查
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const name = getName(item);
|
|
55
|
+
|
|
56
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
57
|
+
Logger.error({ item: item }, `${moduleLabel} 名称解析失败(getName 返回空字符串)`);
|
|
58
|
+
isPass = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (nameToItem[name]) {
|
|
63
|
+
Logger.error(
|
|
64
|
+
{
|
|
65
|
+
name: name,
|
|
66
|
+
first: nameToItem[name],
|
|
67
|
+
second: item
|
|
68
|
+
},
|
|
69
|
+
`${moduleLabel} 名称重复,无法根据 deps 唯一定位`
|
|
70
|
+
);
|
|
71
|
+
isPass = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
nameToItem[name] = item;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!isPass) return false;
|
|
79
|
+
|
|
80
|
+
// 2) 依赖存在性检查 + deps 类型检查
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
const name = getName(item);
|
|
83
|
+
const deps = getDeps(item);
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(deps)) {
|
|
86
|
+
Logger.error({ module: name, item: item }, `${moduleLabel} 的 deps 必须是数组`);
|
|
87
|
+
isPass = false;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const dep of deps) {
|
|
92
|
+
if (typeof dep !== "string") {
|
|
93
|
+
Logger.error({ module: name, dependency: dep, item: item }, `${moduleLabel} 的 deps 必须是字符串数组`);
|
|
94
|
+
isPass = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!nameToItem[dep]) {
|
|
99
|
+
Logger.error({ module: name, dependency: dep }, `${moduleLabel} 依赖未找到`);
|
|
100
|
+
isPass = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isPass) return false;
|
|
106
|
+
|
|
107
|
+
// 3) 拓扑排序(DFS)
|
|
108
|
+
const visit = (name: string): void => {
|
|
109
|
+
if (visited.has(name)) return;
|
|
110
|
+
if (visiting.has(name)) {
|
|
111
|
+
Logger.error({ module: name }, `${moduleLabel} 循环依赖`);
|
|
112
|
+
isPass = false;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const item = nameToItem[name];
|
|
117
|
+
if (!item) return;
|
|
118
|
+
|
|
119
|
+
const deps = getDeps(item) as string[];
|
|
120
|
+
|
|
121
|
+
visiting.add(name);
|
|
122
|
+
for (const dep of deps) {
|
|
123
|
+
visit(dep);
|
|
124
|
+
}
|
|
125
|
+
visiting.delete(name);
|
|
126
|
+
|
|
127
|
+
visited.add(name);
|
|
128
|
+
result.push(item);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const name = getName(item);
|
|
133
|
+
visit(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return isPass ? result : false;
|
|
137
|
+
}
|
package/checks/checkApp.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
// 内部依赖
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
// 相对导入
|
|
6
|
-
import { Logger } from "../lib/logger.js";
|
|
7
|
-
import { projectApiDir, projectDir } from "../paths.js";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* 检查项目结构
|
|
11
|
-
*/
|
|
12
|
-
export async function checkApp(): Promise<void> {
|
|
13
|
-
try {
|
|
14
|
-
// 检查项目 apis 目录下是否存在名为 addon 的目录
|
|
15
|
-
if (existsSync(projectApiDir)) {
|
|
16
|
-
const addonDir = join(projectApiDir, "addon");
|
|
17
|
-
if (existsSync(addonDir)) {
|
|
18
|
-
throw new Error("项目 apis 目录下不能存在名为 addon 的目录,addon 是保留名称,用于组件接口路由");
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// 检查并创建 logs 目录
|
|
23
|
-
const logsDir = join(projectDir, "logs");
|
|
24
|
-
if (!existsSync(logsDir)) {
|
|
25
|
-
mkdirSync(logsDir, { recursive: true });
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// 检查并创建 configs 目录和配置文件
|
|
29
|
-
const configsDir = join(projectDir, "configs");
|
|
30
|
-
if (!existsSync(configsDir)) {
|
|
31
|
-
mkdirSync(configsDir, { recursive: true });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// 检查并创建 befly.common.json
|
|
35
|
-
const beflyJsonPath = join(configsDir, "befly.common.json");
|
|
36
|
-
if (!existsSync(beflyJsonPath)) {
|
|
37
|
-
writeFileSync(beflyJsonPath, "{}", "utf-8");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 检查并创建 befly.development.json
|
|
41
|
-
const beflyDevelopmentJsonPath = join(configsDir, "befly.development.json");
|
|
42
|
-
if (!existsSync(beflyDevelopmentJsonPath)) {
|
|
43
|
-
writeFileSync(beflyDevelopmentJsonPath, "{}", "utf-8");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// 检查并创建 befly.production.json
|
|
47
|
-
const beflyProductionJsonPath = join(configsDir, "befly.production.json");
|
|
48
|
-
if (!existsSync(beflyProductionJsonPath)) {
|
|
49
|
-
writeFileSync(beflyProductionJsonPath, "{}", "utf-8");
|
|
50
|
-
}
|
|
51
|
-
} catch (error: any) {
|
|
52
|
-
Logger.error("项目结构检查过程中出错", error);
|
|
53
|
-
throw error;
|
|
54
|
-
}
|
|
55
|
-
}
|
package/hooks/rateLimit.ts
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
// 类型导入
|
|
2
|
-
import type { Hook } from "../types/hook.js";
|
|
3
|
-
|
|
4
|
-
import { beflyConfig } from "../befly.config.js";
|
|
5
|
-
// 相对导入
|
|
6
|
-
import { ErrorResponse } from "../utils/response.js";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 限流 key 维度
|
|
10
|
-
* - ip: 仅按 IP
|
|
11
|
-
* - user: 仅按用户(缺失 ctx.user.id 时回退为按 IP,避免匿名共享同一计数桶)
|
|
12
|
-
* - ip_user: IP + 用户(缺失用户时视为 anonymous)
|
|
13
|
-
*/
|
|
14
|
-
type RateLimitKeyMode = "ip" | "user" | "ip_user";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 单条限流规则
|
|
18
|
-
* route 匹配串:
|
|
19
|
-
* - 精确:"POST/api/auth/login"
|
|
20
|
-
* - 前缀:"POST/api/auth/*" 或 "/api/auth/*"
|
|
21
|
-
* - 全量:"*"
|
|
22
|
-
*/
|
|
23
|
-
type RateLimitRule = {
|
|
24
|
-
route: string;
|
|
25
|
-
/** 窗口期内允许次数 */
|
|
26
|
-
limit: number;
|
|
27
|
-
/** 窗口期秒数 */
|
|
28
|
-
window: number;
|
|
29
|
-
/** 计数维度(默认 ip) */
|
|
30
|
-
key?: RateLimitKeyMode;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/** 全局请求限流配置(Hook) */
|
|
34
|
-
type RateLimitConfig = {
|
|
35
|
-
/** 是否启用 (0/1) */
|
|
36
|
-
enable?: number;
|
|
37
|
-
/** 未命中 rules 时的默认次数(<=0 表示不启用默认规则) */
|
|
38
|
-
defaultLimit?: number;
|
|
39
|
-
/** 未命中 rules 时的默认窗口秒数(<=0 表示不启用默认规则) */
|
|
40
|
-
defaultWindow?: number;
|
|
41
|
-
/** 默认计数维度(默认 ip) */
|
|
42
|
-
key?: RateLimitKeyMode;
|
|
43
|
-
/**
|
|
44
|
-
* 直接跳过限流的路由列表(优先级最高)
|
|
45
|
-
* - 精确:"POST/api/health" 或 "/api/health"
|
|
46
|
-
* - 前缀:"POST/api/health/*" 或 "/api/health/*"
|
|
47
|
-
*/
|
|
48
|
-
skipRoutes?: string[];
|
|
49
|
-
/** 路由规则列表 */
|
|
50
|
-
rules?: RateLimitRule[];
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
type MemoryBucket = {
|
|
54
|
-
count: number;
|
|
55
|
-
resetAt: number;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const memoryBuckets = new Map<string, MemoryBucket>();
|
|
59
|
-
let nextSweepAt = 0;
|
|
60
|
-
|
|
61
|
-
function matchRoute(ruleRoute: string, actualRoute: string): boolean {
|
|
62
|
-
if (ruleRoute === "*") return true;
|
|
63
|
-
|
|
64
|
-
if (ruleRoute.endsWith("*")) {
|
|
65
|
-
const prefix = ruleRoute.slice(0, -1);
|
|
66
|
-
if (prefix.startsWith("/")) {
|
|
67
|
-
return actualRoute.includes(prefix);
|
|
68
|
-
}
|
|
69
|
-
return actualRoute.startsWith(prefix);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (ruleRoute.startsWith("/")) {
|
|
73
|
-
return actualRoute.endsWith(ruleRoute);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return actualRoute === ruleRoute;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function calcRouteMatchScore(ruleRoute: string, actualRoute: string): number {
|
|
80
|
-
if (!ruleRoute || typeof ruleRoute !== "string") return -1;
|
|
81
|
-
|
|
82
|
-
// 兜底通配:最低优先级
|
|
83
|
-
if (ruleRoute === "*") return 0;
|
|
84
|
-
|
|
85
|
-
// 完全精确:最高优先级
|
|
86
|
-
if (ruleRoute === actualRoute) return 400000 + ruleRoute.length;
|
|
87
|
-
|
|
88
|
-
// 以 / 开头:只匹配 path(忽略 method),用于 /api/* 这类
|
|
89
|
-
if (ruleRoute.startsWith("/")) {
|
|
90
|
-
if (ruleRoute.endsWith("*")) {
|
|
91
|
-
const prefix = ruleRoute.slice(0, -1);
|
|
92
|
-
if (actualRoute.includes(prefix)) {
|
|
93
|
-
return 100000 + prefix.length;
|
|
94
|
-
}
|
|
95
|
-
return -1;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (actualRoute.endsWith(ruleRoute)) {
|
|
99
|
-
return 300000 + ruleRoute.length;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return -1;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 不以 / 开头:匹配包含 method 的完整串,如 POST/api/auth/*
|
|
106
|
-
if (ruleRoute.endsWith("*")) {
|
|
107
|
-
const prefix = ruleRoute.slice(0, -1);
|
|
108
|
-
if (actualRoute.startsWith(prefix)) {
|
|
109
|
-
return 200000 + prefix.length;
|
|
110
|
-
}
|
|
111
|
-
return -1;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 其他兜底(理论上到不了,因为精确已在上面处理;这里为了兼容 matchRoute 的未来扩展)
|
|
115
|
-
if (matchRoute(ruleRoute, actualRoute)) {
|
|
116
|
-
return 50000 + ruleRoute.length;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return -1;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function shouldSkip(config: RateLimitConfig, actualRoute: string): boolean {
|
|
123
|
-
const skipRoutes = Array.isArray(config.skipRoutes) ? config.skipRoutes : [];
|
|
124
|
-
if (skipRoutes.length === 0) return false;
|
|
125
|
-
|
|
126
|
-
for (const skip of skipRoutes) {
|
|
127
|
-
if (typeof skip !== "string" || !skip) continue;
|
|
128
|
-
if (matchRoute(skip, actualRoute)) return true;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function pickRule(config: RateLimitConfig, actualRoute: string): RateLimitRule | null {
|
|
135
|
-
const rules = Array.isArray(config.rules) ? config.rules : [];
|
|
136
|
-
|
|
137
|
-
let bestRule: RateLimitRule | null = null;
|
|
138
|
-
let bestScore = -1;
|
|
139
|
-
|
|
140
|
-
// 多条命中时,优先更“具体”的规则(精确 > 前缀 > 通配);同等具体度按 rules 的先后顺序
|
|
141
|
-
for (const rule of rules) {
|
|
142
|
-
if (!rule || typeof rule.route !== "string") continue;
|
|
143
|
-
|
|
144
|
-
const score = calcRouteMatchScore(rule.route, actualRoute);
|
|
145
|
-
if (score > bestScore) {
|
|
146
|
-
bestRule = rule;
|
|
147
|
-
bestScore = score;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (bestRule) return bestRule;
|
|
152
|
-
|
|
153
|
-
const defaultLimit = typeof config.defaultLimit === "number" ? config.defaultLimit : 0;
|
|
154
|
-
const defaultWindow = typeof config.defaultWindow === "number" ? config.defaultWindow : 0;
|
|
155
|
-
|
|
156
|
-
if (defaultLimit > 0 && defaultWindow > 0) {
|
|
157
|
-
return {
|
|
158
|
-
route: "*",
|
|
159
|
-
limit: defaultLimit,
|
|
160
|
-
window: defaultWindow,
|
|
161
|
-
key: config.key
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function buildIdentity(ctx: any, mode: RateLimitKeyMode): string {
|
|
169
|
-
const ip = typeof ctx.ip === "string" ? ctx.ip : "unknown";
|
|
170
|
-
|
|
171
|
-
const userIdValue = ctx.user && (typeof ctx.user.id === "number" || typeof ctx.user.id === "string") ? ctx.user.id : null;
|
|
172
|
-
const userId = userIdValue !== null ? String(userIdValue) : "";
|
|
173
|
-
|
|
174
|
-
if (mode === "ip") {
|
|
175
|
-
return `ip:${ip}`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (mode === "user") {
|
|
179
|
-
// 未登录/无 userId:回退为按 IP 计数,避免所有匿名用户共享同一 bucket
|
|
180
|
-
if (userId) return `user:${userId}`;
|
|
181
|
-
return `ip:${ip}`;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (mode === "ip_user") {
|
|
185
|
-
if (userId) {
|
|
186
|
-
return `ip:${ip}:user:${userId}`;
|
|
187
|
-
}
|
|
188
|
-
return `ip:${ip}:user:anonymous`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return `ip:${ip}`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function hitMemoryBucket(key: string, windowSeconds: number): number {
|
|
195
|
-
const now = Date.now();
|
|
196
|
-
|
|
197
|
-
if (now >= nextSweepAt) {
|
|
198
|
-
nextSweepAt = now + 60_000;
|
|
199
|
-
for (const [k, v] of memoryBuckets.entries()) {
|
|
200
|
-
if (!v || typeof v.resetAt !== "number") {
|
|
201
|
-
memoryBuckets.delete(k);
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (v.resetAt <= now) {
|
|
205
|
-
memoryBuckets.delete(k);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const existing = memoryBuckets.get(key);
|
|
211
|
-
if (!existing || existing.resetAt <= now) {
|
|
212
|
-
const bucket: MemoryBucket = {
|
|
213
|
-
count: 1,
|
|
214
|
-
resetAt: now + windowSeconds * 1000
|
|
215
|
-
};
|
|
216
|
-
memoryBuckets.set(key, bucket);
|
|
217
|
-
return bucket.count;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
existing.count += 1;
|
|
221
|
-
return existing.count;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* 请求限流钩子(全局)
|
|
226
|
-
* - 通过 beflyConfig.rateLimit 开启/配置
|
|
227
|
-
* - 默认启用:可通过配置禁用或调整阈值
|
|
228
|
-
*/
|
|
229
|
-
const hook: Hook = {
|
|
230
|
-
order: 7,
|
|
231
|
-
handler: async (befly, ctx) => {
|
|
232
|
-
const config = beflyConfig.rateLimit as RateLimitConfig | undefined;
|
|
233
|
-
|
|
234
|
-
if (!config || config.enable !== 1) return;
|
|
235
|
-
if (!ctx.api) return;
|
|
236
|
-
if (ctx.req && ctx.req.method === "OPTIONS") return;
|
|
237
|
-
|
|
238
|
-
// 跳过名单:命中后不计数也不拦截(优先级最高)
|
|
239
|
-
if (shouldSkip(config, ctx.route)) return;
|
|
240
|
-
|
|
241
|
-
const rule = pickRule(config, ctx.route);
|
|
242
|
-
if (!rule) return;
|
|
243
|
-
|
|
244
|
-
const limit = typeof rule.limit === "number" ? rule.limit : 0;
|
|
245
|
-
const windowSeconds = typeof rule.window === "number" ? rule.window : 0;
|
|
246
|
-
if (limit <= 0 || windowSeconds <= 0) return;
|
|
247
|
-
|
|
248
|
-
const keyMode = rule.key || config.key || "ip";
|
|
249
|
-
const identity = buildIdentity(ctx, keyMode);
|
|
250
|
-
const counterKey = `rate_limit:${ctx.route}:${identity}`;
|
|
251
|
-
|
|
252
|
-
let count = 0;
|
|
253
|
-
if (befly.redis) {
|
|
254
|
-
count = await befly.redis.incrWithExpire(counterKey, windowSeconds);
|
|
255
|
-
} else {
|
|
256
|
-
count = hitMemoryBucket(counterKey, windowSeconds);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (count > limit) {
|
|
260
|
-
ctx.response = ErrorResponse(
|
|
261
|
-
ctx,
|
|
262
|
-
"请求过于频繁,请稍后再试",
|
|
263
|
-
1,
|
|
264
|
-
null,
|
|
265
|
-
{
|
|
266
|
-
limit: limit,
|
|
267
|
-
window: windowSeconds
|
|
268
|
-
},
|
|
269
|
-
"rateLimit"
|
|
270
|
-
);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
export default hook;
|
package/sync/syncAll.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sync 命令 - 一次性执行所有同步操作
|
|
3
|
-
* 按顺序执行:syncDb → syncApi → syncMenu → syncDev(syncDev 内会重建角色接口权限缓存)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { SyncOptions } from "../types/sync.js";
|
|
7
|
-
|
|
8
|
-
import { checkApp } from "../checks/checkApp.js";
|
|
9
|
-
import { Logger } from "../lib/logger.js";
|
|
10
|
-
import { syncApiCommand } from "./syncApi.js";
|
|
11
|
-
import { syncDbCommand } from "./syncDb.js";
|
|
12
|
-
import { syncDevCommand } from "./syncDev.js";
|
|
13
|
-
import { syncMenuCommand } from "./syncMenu.js";
|
|
14
|
-
|
|
15
|
-
export async function syncAllCommand(options: SyncOptions = {}) {
|
|
16
|
-
try {
|
|
17
|
-
// 0. 检查项目结构
|
|
18
|
-
await checkApp();
|
|
19
|
-
|
|
20
|
-
// 1. 同步数据库表结构
|
|
21
|
-
await syncDbCommand({ dryRun: false, force: options.force || false });
|
|
22
|
-
|
|
23
|
-
// 2. 同步接口(并缓存)
|
|
24
|
-
await syncApiCommand();
|
|
25
|
-
|
|
26
|
-
// 3. 同步菜单(并缓存)
|
|
27
|
-
await syncMenuCommand();
|
|
28
|
-
|
|
29
|
-
// 4. 同步开发管理员(syncDev 内会重建角色接口权限缓存)
|
|
30
|
-
await syncDevCommand();
|
|
31
|
-
} catch (error: any) {
|
|
32
|
-
Logger.error({ err: error }, "同步过程中发生错误");
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
}
|
package/sync/syncDb/apply.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* syncDb 变更应用模块
|
|
3
|
-
*
|
|
4
|
-
* 包含:
|
|
5
|
-
* - 比较字段定义变化
|
|
6
|
-
* - 应用表结构变更计划
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { FieldChange, TablePlan, ColumnInfo } from "../../types/sync.js";
|
|
10
|
-
import type { FieldDefinition } from "../../types/validate.js";
|
|
11
|
-
import type { SQL } from "bun";
|
|
12
|
-
|
|
13
|
-
import { Logger } from "../../lib/logger.js";
|
|
14
|
-
import { isMySQL, isPG, isSQLite, IS_PLAN, getTypeMapping } from "./constants.js";
|
|
15
|
-
import { executeDDLSafely, buildIndexSQL } from "./ddl.js";
|
|
16
|
-
import { rebuildSqliteTable } from "./sqlite.js";
|
|
17
|
-
import { isStringOrArrayType, resolveDefaultValue } from "./types.js";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* 构建 ALTER TABLE SQL 语句
|
|
21
|
-
*
|
|
22
|
-
* 根据数据库类型构建相应的 ALTER TABLE 语句:
|
|
23
|
-
* - MySQL: 添加 ALGORITHM=INSTANT, LOCK=NONE 优化参数
|
|
24
|
-
* - PostgreSQL/SQLite: 使用双引号标识符
|
|
25
|
-
*
|
|
26
|
-
* @param tableName - 表名
|
|
27
|
-
* @param clauses - SQL 子句数组
|
|
28
|
-
* @returns 完整的 ALTER TABLE 语句
|
|
29
|
-
*/
|
|
30
|
-
function buildAlterTableSQL(tableName: string, clauses: string[]): string {
|
|
31
|
-
if (isMySQL()) {
|
|
32
|
-
return `ALTER TABLE \`${tableName}\` ${clauses.join(", ")}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
33
|
-
}
|
|
34
|
-
return `ALTER TABLE "${tableName}" ${clauses.join(", ")}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 比较字段定义变化
|
|
39
|
-
*
|
|
40
|
-
* 对比现有列信息和新的字段规则,识别变化类型:
|
|
41
|
-
* - 长度变化(string/array 类型)
|
|
42
|
-
* - 注释变化(MySQL/PG)
|
|
43
|
-
* - 数据类型变化
|
|
44
|
-
* - 默认值变化
|
|
45
|
-
*
|
|
46
|
-
* @param existingColumn - 现有列信息
|
|
47
|
-
* @param fieldDef - 新的字段定义对象
|
|
48
|
-
* @param colName - 列名(未使用,保留参数兼容性)
|
|
49
|
-
* @returns 变化数组
|
|
50
|
-
*/
|
|
51
|
-
export function compareFieldDefinition(existingColumn: ColumnInfo, fieldDef: FieldDefinition): FieldChange[] {
|
|
52
|
-
const changes: FieldChange[] = [];
|
|
53
|
-
|
|
54
|
-
// 检查长度变化(string和array类型) - SQLite 不比较长度
|
|
55
|
-
if (!isSQLite() && isStringOrArrayType(fieldDef.type)) {
|
|
56
|
-
if (existingColumn.max !== fieldDef.max) {
|
|
57
|
-
changes.push({
|
|
58
|
-
type: "length",
|
|
59
|
-
current: existingColumn.max,
|
|
60
|
-
expected: fieldDef.max
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// 检查注释变化(MySQL/PG 支持列注释,对比数据库 comment 与字段 name)
|
|
66
|
-
if (!isSQLite()) {
|
|
67
|
-
const currentComment = existingColumn.comment || "";
|
|
68
|
-
if (currentComment !== fieldDef.name) {
|
|
69
|
-
changes.push({
|
|
70
|
-
type: "comment",
|
|
71
|
-
current: currentComment,
|
|
72
|
-
expected: fieldDef.name
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 检查数据类型变化(只对比基础类型)
|
|
78
|
-
const typeMapping = getTypeMapping();
|
|
79
|
-
const expectedType = typeMapping[fieldDef.type].toLowerCase();
|
|
80
|
-
const currentType = existingColumn.type.toLowerCase();
|
|
81
|
-
|
|
82
|
-
if (currentType !== expectedType) {
|
|
83
|
-
changes.push({
|
|
84
|
-
type: "datatype",
|
|
85
|
-
current: currentType,
|
|
86
|
-
expected: expectedType
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// 检查 nullable 变化
|
|
91
|
-
const expectedNullable = fieldDef.nullable;
|
|
92
|
-
if (existingColumn.nullable !== expectedNullable) {
|
|
93
|
-
changes.push({
|
|
94
|
-
type: "nullable",
|
|
95
|
-
current: existingColumn.nullable,
|
|
96
|
-
expected: expectedNullable
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 使用公共函数处理默认值
|
|
101
|
-
const expectedDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
|
|
102
|
-
|
|
103
|
-
// 检查默认值变化
|
|
104
|
-
if (String(existingColumn.defaultValue) !== String(expectedDefault)) {
|
|
105
|
-
changes.push({
|
|
106
|
-
type: "default",
|
|
107
|
-
current: existingColumn.defaultValue,
|
|
108
|
-
expected: expectedDefault
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return changes;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* 将表结构计划应用到数据库(执行 DDL/索引/注释等)
|
|
117
|
-
*
|
|
118
|
-
* 根据数据库方言和计划内容,执行相应的 DDL 操作:
|
|
119
|
-
* - SQLite: 新增字段直接 ALTER,其他操作需要重建表
|
|
120
|
-
* - MySQL: 尝试在线 DDL(INSTANT/INPLACE)
|
|
121
|
-
* - PostgreSQL: 直接 ALTER
|
|
122
|
-
*
|
|
123
|
-
* @param sql - SQL 客户端实例
|
|
124
|
-
* @param tableName - 表名
|
|
125
|
-
* @param fields - 字段定义对象
|
|
126
|
-
* @param plan - 表结构变更计划
|
|
127
|
-
*/
|
|
128
|
-
export async function applyTablePlan(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, plan: TablePlan): Promise<void> {
|
|
129
|
-
if (!plan || !plan.changed) return;
|
|
130
|
-
|
|
131
|
-
// SQLite: 仅支持部分 ALTER;需要时走重建
|
|
132
|
-
if (isSQLite()) {
|
|
133
|
-
if (plan.modifyClauses.length > 0 || plan.defaultClauses.length > 0) {
|
|
134
|
-
if (IS_PLAN) Logger.debug(`[计划] 重建表 ${tableName} 以应用列修改/默认值变化`);
|
|
135
|
-
else await rebuildSqliteTable(sql, tableName, fields);
|
|
136
|
-
} else {
|
|
137
|
-
for (const c of plan.addClauses) {
|
|
138
|
-
const stmt = `ALTER TABLE "${tableName}" ${c}`;
|
|
139
|
-
if (IS_PLAN) Logger.debug(`[计划] ${stmt}`);
|
|
140
|
-
else await sql.unsafe(stmt);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
} else {
|
|
144
|
-
const clauses = [...plan.addClauses, ...plan.modifyClauses];
|
|
145
|
-
if (clauses.length > 0) {
|
|
146
|
-
const stmt = buildAlterTableSQL(tableName, clauses);
|
|
147
|
-
if (IS_PLAN) Logger.debug(`[计划] ${stmt}`);
|
|
148
|
-
else if (isMySQL()) await executeDDLSafely(sql, stmt);
|
|
149
|
-
else await sql.unsafe(stmt);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// 默认值专用 ALTER(SQLite 不支持)
|
|
154
|
-
if (plan.defaultClauses.length > 0) {
|
|
155
|
-
if (isSQLite()) {
|
|
156
|
-
Logger.warn(`SQLite 不支持修改默认值,表 ${tableName} 的默认值变更已跳过`);
|
|
157
|
-
} else {
|
|
158
|
-
const stmt = buildAlterTableSQL(tableName, plan.defaultClauses);
|
|
159
|
-
if (IS_PLAN) Logger.debug(`[计划] ${stmt}`);
|
|
160
|
-
else if (isMySQL()) await executeDDLSafely(sql, stmt);
|
|
161
|
-
else await sql.unsafe(stmt);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 索引操作
|
|
166
|
-
for (const act of plan.indexActions) {
|
|
167
|
-
const stmt = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
|
|
168
|
-
if (IS_PLAN) {
|
|
169
|
-
Logger.debug(`[计划] ${stmt}`);
|
|
170
|
-
} else {
|
|
171
|
-
try {
|
|
172
|
-
await sql.unsafe(stmt);
|
|
173
|
-
if (act.action === "create") {
|
|
174
|
-
Logger.debug(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
175
|
-
} else {
|
|
176
|
-
Logger.debug(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
177
|
-
}
|
|
178
|
-
} catch (error: any) {
|
|
179
|
-
Logger.error({ err: error, table: tableName, index: act.indexName, field: act.fieldName }, `${act.action === "create" ? "创建" : "删除"}索引失败`);
|
|
180
|
-
throw error;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// PG 列注释
|
|
186
|
-
if (isPG() && plan.commentActions && plan.commentActions.length > 0) {
|
|
187
|
-
for (const stmt of plan.commentActions) {
|
|
188
|
-
if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
|
|
189
|
-
else await sql.unsafe(stmt);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|