befly 3.9.40 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -8
- package/befly.config.ts +19 -2
- package/checks/checkApi.ts +79 -77
- package/checks/checkHook.ts +48 -0
- package/checks/checkMenu.ts +168 -0
- package/checks/checkPlugin.ts +48 -0
- package/checks/checkTable.ts +137 -183
- package/docs/README.md +1 -1
- package/docs/api/api.md +1 -1
- package/docs/guide/quickstart.md +16 -9
- package/docs/hooks/hook.md +2 -2
- package/docs/hooks/rateLimit.md +1 -1
- package/docs/infra/redis.md +7 -7
- package/docs/plugins/plugin.md +23 -21
- package/docs/quickstart.md +16 -9
- package/docs/reference/addon.md +12 -1
- package/docs/reference/config.md +13 -30
- package/docs/reference/sync.md +62 -193
- package/docs/reference/table.md +27 -29
- package/hooks/auth.ts +3 -4
- package/hooks/cors.ts +4 -6
- package/hooks/parser.ts +3 -4
- package/hooks/permission.ts +3 -4
- package/hooks/validator.ts +3 -4
- package/lib/cacheHelper.ts +89 -153
- package/lib/cacheKeys.ts +1 -1
- package/lib/connect.ts +9 -13
- package/lib/dbDialect.ts +285 -0
- package/lib/dbHelper.ts +179 -507
- package/lib/dbUtils.ts +450 -0
- package/lib/logger.ts +41 -5
- package/lib/redisHelper.ts +1 -0
- package/lib/sqlBuilder.ts +358 -58
- package/lib/sqlCheck.ts +136 -0
- package/lib/validator.ts +1 -1
- package/loader/loadApis.ts +23 -126
- package/loader/loadHooks.ts +31 -46
- package/loader/loadPlugins.ts +37 -52
- package/main.ts +58 -19
- package/package.json +24 -25
- package/paths.ts +14 -14
- package/plugins/cache.ts +12 -6
- package/plugins/cipher.ts +2 -2
- package/plugins/config.ts +6 -8
- package/plugins/db.ts +14 -19
- package/plugins/jwt.ts +6 -7
- package/plugins/logger.ts +7 -9
- package/plugins/redis.ts +8 -10
- package/plugins/tool.ts +3 -4
- package/router/api.ts +3 -2
- package/router/static.ts +7 -5
- package/sync/syncApi.ts +80 -235
- package/sync/syncCache.ts +16 -0
- package/sync/syncDev.ts +167 -202
- package/sync/syncMenu.ts +230 -444
- package/sync/syncTable.ts +1247 -0
- package/tests/_mocks/mockSqliteDb.ts +204 -0
- package/tests/addonHelper-cache.test.ts +32 -0
- package/tests/apiHandler-routePath-only.test.ts +32 -0
- package/tests/cacheHelper.test.ts +16 -51
- package/tests/checkApi-routePath-strict.test.ts +166 -0
- package/tests/checkMenu.test.ts +346 -0
- package/tests/checkTable-smoke.test.ts +157 -0
- package/tests/dbDialect-cache.test.ts +23 -0
- package/tests/dbDialect.test.ts +46 -0
- package/tests/dbHelper-advanced.test.ts +1 -1
- package/tests/dbHelper-all-array-types.test.ts +15 -15
- package/tests/dbHelper-batch-write.test.ts +90 -0
- package/tests/dbHelper-columns.test.ts +36 -54
- package/tests/dbHelper-execute.test.ts +26 -26
- package/tests/dbHelper-joins.test.ts +85 -176
- package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
- package/tests/fixtures/scanFilesApis/a.ts +3 -0
- package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
- package/tests/loadPlugins-order-smoke.test.ts +75 -0
- package/tests/logger.test.ts +6 -6
- package/tests/redisHelper.test.ts +6 -1
- package/tests/scanFiles-routePath.test.ts +46 -0
- package/tests/smoke-sql.test.ts +24 -0
- package/tests/sqlBuilder-advanced.test.ts +18 -5
- package/tests/sqlBuilder.test.ts +24 -0
- package/tests/sync-init-guard.test.ts +105 -0
- package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
- package/tests/syncApi-obsolete-records.test.ts +69 -0
- package/tests/syncApi-type-compat.test.ts +72 -0
- package/tests/syncDev-permissions.test.ts +81 -0
- package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
- package/tests/syncMenu-duplicate-path.test.ts +122 -0
- package/tests/syncMenu-obsolete-records.test.ts +161 -0
- package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
- package/tests/syncMenu-paths.test.ts +0 -9
- package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
- package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
- package/tests/syncTable-constants.test.ts +101 -0
- package/tests/syncTable-db-integration.test.ts +237 -0
- package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
- package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
- package/tests/syncTable-schema.test.ts +99 -0
- package/tests/syncTable-testkit.test.ts +25 -0
- package/tests/syncTable-types.test.ts +122 -0
- package/tests/tableRef-and-deserialize.test.ts +67 -0
- package/tsconfig.json +1 -1
- package/types/api.d.ts +1 -1
- package/types/befly.d.ts +13 -12
- package/types/cache.d.ts +2 -2
- package/types/context.d.ts +1 -1
- package/types/database.d.ts +0 -5
- package/types/hook.d.ts +1 -10
- package/types/plugin.d.ts +2 -96
- package/types/sync.d.ts +19 -25
- package/utils/convertBigIntFields.ts +38 -0
- package/utils/disableMenusGlob.ts +85 -0
- package/utils/importDefault.ts +21 -0
- package/utils/isDirentDirectory.ts +23 -0
- package/utils/loadMenuConfigs.ts +145 -0
- package/utils/processFields.ts +25 -0
- package/utils/scanAddons.ts +72 -0
- package/utils/scanFiles.ts +129 -21
- package/utils/scanSources.ts +64 -0
- package/utils/sortModules.ts +137 -0
- package/checks/checkApp.ts +0 -55
- package/hooks/rateLimit.ts +0 -276
- package/sync/syncAll.ts +0 -35
- package/sync/syncDb/apply.ts +0 -192
- package/sync/syncDb/constants.ts +0 -119
- package/sync/syncDb/ddl.ts +0 -251
- package/sync/syncDb/helpers.ts +0 -84
- package/sync/syncDb/schema.ts +0 -202
- package/sync/syncDb/sqlite.ts +0 -48
- package/sync/syncDb/table.ts +0 -207
- package/sync/syncDb/tableCreate.ts +0 -163
- package/sync/syncDb/types.ts +0 -132
- package/sync/syncDb/version.ts +0 -69
- package/sync/syncDb.ts +0 -168
- package/tests/rateLimit-hook.test.ts +0 -477
- package/tests/syncDb-constants.test.ts +0 -130
- package/tests/syncDb-schema.test.ts +0 -179
- package/tests/syncDb-types.test.ts +0 -139
- package/utils/addonHelper.ts +0 -90
- package/utils/modules.ts +0 -98
- package/utils/route.ts +0 -23
package/sync/syncDb.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SyncDb 命令 - 同步数据库表结构
|
|
3
|
-
*
|
|
4
|
-
* 功能:
|
|
5
|
-
* - 协调所有模块,执行数据库表结构同步
|
|
6
|
-
* - 处理核心表、项目表、addon 表
|
|
7
|
-
* - 提供统计信息和错误处理
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { SyncDbOptions } from "../types/sync.js";
|
|
11
|
-
import type { SQL } from "bun";
|
|
12
|
-
|
|
13
|
-
import { existsSync } from "node:fs";
|
|
14
|
-
|
|
15
|
-
import { snakeCase } from "es-toolkit/string";
|
|
16
|
-
import { resolve } from "pathe";
|
|
17
|
-
|
|
18
|
-
import { beflyConfig } from "../befly.config.js";
|
|
19
|
-
import { checkTable } from "../checks/checkTable.js";
|
|
20
|
-
import { CacheKeys } from "../lib/cacheKeys.js";
|
|
21
|
-
import { Connect } from "../lib/connect.js";
|
|
22
|
-
import { Logger } from "../lib/logger.js";
|
|
23
|
-
import { RedisHelper } from "../lib/redisHelper.js";
|
|
24
|
-
import { projectDir } from "../paths.js";
|
|
25
|
-
import { scanAddons, addonDirExists, getAddonDir } from "../utils/addonHelper.js";
|
|
26
|
-
import { scanFiles } from "../utils/scanFiles.js";
|
|
27
|
-
import { setDbType } from "./syncDb/constants.js";
|
|
28
|
-
import { applyFieldDefaults } from "./syncDb/helpers.js";
|
|
29
|
-
import { tableExists } from "./syncDb/schema.js";
|
|
30
|
-
import { modifyTable } from "./syncDb/table.js";
|
|
31
|
-
import { createTable } from "./syncDb/tableCreate.js";
|
|
32
|
-
// 导入模块化的功能
|
|
33
|
-
import { ensureDbVersion } from "./syncDb/version.js";
|
|
34
|
-
|
|
35
|
-
// 全局 SQL 客户端实例
|
|
36
|
-
let sql: SQL | null = null;
|
|
37
|
-
|
|
38
|
-
// 记录处理过的表名(用于清理缓存)
|
|
39
|
-
const processedTables: string[] = [];
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* syncDbCommand - 数据库同步命令入口
|
|
43
|
-
*
|
|
44
|
-
* 流程:
|
|
45
|
-
* 1. 验证表定义文件
|
|
46
|
-
* 2. 建立数据库连接并检查版本
|
|
47
|
-
* 3. 扫描表定义文件(核心表、项目表、addon表)
|
|
48
|
-
* 4. 对比并应用表结构变更
|
|
49
|
-
*/
|
|
50
|
-
export async function syncDbCommand(options: SyncDbOptions = {}): Promise<void> {
|
|
51
|
-
try {
|
|
52
|
-
// 清空处理记录
|
|
53
|
-
processedTables.length = 0;
|
|
54
|
-
|
|
55
|
-
// 设置数据库类型(从配置获取)
|
|
56
|
-
const dbType = beflyConfig.db?.type || "mysql";
|
|
57
|
-
setDbType(dbType);
|
|
58
|
-
|
|
59
|
-
// 验证表定义文件
|
|
60
|
-
await checkTable();
|
|
61
|
-
|
|
62
|
-
// 建立数据库连接并检查版本
|
|
63
|
-
sql = await Connect.connectSql();
|
|
64
|
-
await ensureDbVersion(sql);
|
|
65
|
-
|
|
66
|
-
// 初始化 Redis 连接(用于清理缓存)
|
|
67
|
-
await Connect.connectRedis();
|
|
68
|
-
|
|
69
|
-
// 扫描表定义文件
|
|
70
|
-
const directories: Array<{
|
|
71
|
-
path: string;
|
|
72
|
-
type: "app" | "addon";
|
|
73
|
-
addonName?: string;
|
|
74
|
-
addonNameSnake?: string;
|
|
75
|
-
}> = [];
|
|
76
|
-
|
|
77
|
-
// 1. 项目表(无前缀)- 如果 tables 目录存在
|
|
78
|
-
const projectTablesDir = resolve(projectDir, "tables");
|
|
79
|
-
if (existsSync(projectTablesDir)) {
|
|
80
|
-
directories.push({ path: projectTablesDir, type: "app" });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 添加所有 addon 的 tables 目录(addon_{name}_ 前缀)
|
|
84
|
-
const addons = scanAddons();
|
|
85
|
-
for (const addon of addons) {
|
|
86
|
-
if (addonDirExists(addon, "tables")) {
|
|
87
|
-
directories.push({
|
|
88
|
-
path: getAddonDir(addon, "tables"),
|
|
89
|
-
type: "addon",
|
|
90
|
-
addonName: addon,
|
|
91
|
-
addonNameSnake: snakeCase(addon) // 提前转换,避免每个文件都转换
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// 处理表文件
|
|
97
|
-
for (const dirConfig of directories) {
|
|
98
|
-
const { path: dir, type } = dirConfig;
|
|
99
|
-
|
|
100
|
-
const files = await scanFiles(dir, "*.json");
|
|
101
|
-
|
|
102
|
-
for (const { filePath: file, fileName } of files) {
|
|
103
|
-
// 确定表名:
|
|
104
|
-
// - addon 表:{addonName}_{表名}
|
|
105
|
-
// 例如:admin addon 的 user.json → admin_user
|
|
106
|
-
// - 项目表:{表名}
|
|
107
|
-
// 例如:user.json → user
|
|
108
|
-
let tableName = snakeCase(fileName);
|
|
109
|
-
if (type === "addon" && dirConfig.addonNameSnake) {
|
|
110
|
-
// addon 表,使用提前转换好的名称
|
|
111
|
-
tableName = `addon_${dirConfig.addonNameSnake}_${tableName}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 如果指定了表名,则只同步该表
|
|
115
|
-
if (options.table && options.table !== tableName) {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const tableDefinitionModule = await import(file, { with: { type: "json" } });
|
|
120
|
-
const tableDefinition = tableDefinitionModule.default;
|
|
121
|
-
|
|
122
|
-
// 为字段属性设置默认值
|
|
123
|
-
for (const fieldDef of Object.values(tableDefinition)) {
|
|
124
|
-
applyFieldDefaults(fieldDef);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const dbName = beflyConfig.db?.database || "";
|
|
128
|
-
const existsTable = await tableExists(sql!, tableName, dbName);
|
|
129
|
-
|
|
130
|
-
// 读取 force 参数
|
|
131
|
-
const force = options.force || false;
|
|
132
|
-
|
|
133
|
-
if (existsTable) {
|
|
134
|
-
await modifyTable(sql!, tableName, tableDefinition, force, dbName);
|
|
135
|
-
} else {
|
|
136
|
-
await createTable(sql!, tableName, tableDefinition, ["created_at", "updated_at", "state"], dbName);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// 记录处理过的表名(用于清理缓存)
|
|
140
|
-
processedTables.push(tableName);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// 清理 Redis 缓存(如果有表被处理)
|
|
145
|
-
if (processedTables.length > 0) {
|
|
146
|
-
const redisHelper = new RedisHelper();
|
|
147
|
-
const cacheKeys = processedTables.map((tableName) => CacheKeys.tableColumns(tableName));
|
|
148
|
-
await redisHelper.delBatch(cacheKeys);
|
|
149
|
-
}
|
|
150
|
-
} catch (error: any) {
|
|
151
|
-
Logger.error({ err: error }, "数据库同步失败");
|
|
152
|
-
throw error;
|
|
153
|
-
} finally {
|
|
154
|
-
if (sql) {
|
|
155
|
-
try {
|
|
156
|
-
await Connect.disconnectSql();
|
|
157
|
-
} catch (error: any) {
|
|
158
|
-
Logger.warn(`关闭数据库连接时出错: ${error.message}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
await Connect.disconnectRedis();
|
|
164
|
-
} catch (error: any) {
|
|
165
|
-
Logger.warn(`关闭 Redis 连接时出错: ${error.message}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
@@ -1,477 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test, afterEach } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { beflyConfig } from "../befly.config.js";
|
|
4
|
-
import rateLimitHook from "../hooks/rateLimit.js";
|
|
5
|
-
|
|
6
|
-
type MockBefly = {
|
|
7
|
-
redis?: {
|
|
8
|
-
incrWithExpire: (key: string, seconds: number) => Promise<number>;
|
|
9
|
-
};
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
type MockCtx = {
|
|
13
|
-
api: { name: string };
|
|
14
|
-
req: Request;
|
|
15
|
-
route: string;
|
|
16
|
-
ip: string;
|
|
17
|
-
user: Record<string, any>;
|
|
18
|
-
requestId: string;
|
|
19
|
-
corsHeaders: Record<string, string>;
|
|
20
|
-
response?: Response;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const originalRateLimitConfigJson = JSON.stringify(beflyConfig.rateLimit);
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
beflyConfig.rateLimit = JSON.parse(originalRateLimitConfigJson);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("hook - rateLimit", () => {
|
|
30
|
-
test("命中规则:未超限不拦截,超限后拦截", async () => {
|
|
31
|
-
beflyConfig.rateLimit = {
|
|
32
|
-
enable: 1,
|
|
33
|
-
defaultLimit: 0,
|
|
34
|
-
defaultWindow: 0,
|
|
35
|
-
key: "ip",
|
|
36
|
-
rules: [
|
|
37
|
-
{
|
|
38
|
-
route: "/api/auth/*",
|
|
39
|
-
limit: 2,
|
|
40
|
-
window: 60,
|
|
41
|
-
key: "ip"
|
|
42
|
-
}
|
|
43
|
-
]
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
let counter = 0;
|
|
47
|
-
const befly: MockBefly = {
|
|
48
|
-
redis: {
|
|
49
|
-
incrWithExpire: async () => {
|
|
50
|
-
counter += 1;
|
|
51
|
-
return counter;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const ctx: MockCtx = {
|
|
57
|
-
api: { name: "login" },
|
|
58
|
-
req: new Request("http://localhost/api/auth/login", { method: "POST" }),
|
|
59
|
-
route: "POST/api/auth/login",
|
|
60
|
-
ip: "1.1.1.1",
|
|
61
|
-
user: { id: 123 },
|
|
62
|
-
requestId: "rid_rate_1",
|
|
63
|
-
corsHeaders: {}
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
67
|
-
expect(ctx.response).toBeUndefined();
|
|
68
|
-
|
|
69
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
70
|
-
expect(ctx.response).toBeUndefined();
|
|
71
|
-
|
|
72
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
73
|
-
expect(ctx.response).toBeInstanceOf(Response);
|
|
74
|
-
|
|
75
|
-
const payload = await (ctx.response as Response).json();
|
|
76
|
-
expect(payload).toEqual({
|
|
77
|
-
code: 1,
|
|
78
|
-
msg: "请求过于频繁,请稍后再试",
|
|
79
|
-
data: null,
|
|
80
|
-
detail: {
|
|
81
|
-
limit: 2,
|
|
82
|
-
window: 60
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("key 维度为 user:counterKey 包含用户 id", async () => {
|
|
88
|
-
beflyConfig.rateLimit = {
|
|
89
|
-
enable: 1,
|
|
90
|
-
defaultLimit: 0,
|
|
91
|
-
defaultWindow: 0,
|
|
92
|
-
key: "ip",
|
|
93
|
-
rules: [
|
|
94
|
-
{
|
|
95
|
-
route: "*",
|
|
96
|
-
limit: 10,
|
|
97
|
-
window: 60,
|
|
98
|
-
key: "user"
|
|
99
|
-
}
|
|
100
|
-
]
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
let lastKey = "";
|
|
104
|
-
const befly: MockBefly = {
|
|
105
|
-
redis: {
|
|
106
|
-
incrWithExpire: async (key) => {
|
|
107
|
-
lastKey = key;
|
|
108
|
-
return 1;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const ctx: MockCtx = {
|
|
114
|
-
api: { name: "x" },
|
|
115
|
-
req: new Request("http://localhost/api/user/profile", { method: "POST" }),
|
|
116
|
-
route: "POST/api/user/profile",
|
|
117
|
-
ip: "2.2.2.2",
|
|
118
|
-
user: { id: 9 },
|
|
119
|
-
requestId: "rid_rate_2",
|
|
120
|
-
corsHeaders: {}
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
124
|
-
|
|
125
|
-
expect(lastKey.includes(":user:9")).toBe(true);
|
|
126
|
-
expect(lastKey.startsWith("rate_limit:POST/api/user/profile:")).toBe(true);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("key 维度为 ip_user:counterKey 同时包含 ip 与用户 id", async () => {
|
|
130
|
-
beflyConfig.rateLimit = {
|
|
131
|
-
enable: 1,
|
|
132
|
-
defaultLimit: 0,
|
|
133
|
-
defaultWindow: 0,
|
|
134
|
-
key: "ip",
|
|
135
|
-
rules: [
|
|
136
|
-
{
|
|
137
|
-
route: "*",
|
|
138
|
-
limit: 10,
|
|
139
|
-
window: 60,
|
|
140
|
-
key: "ip_user"
|
|
141
|
-
}
|
|
142
|
-
]
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
let lastKey = "";
|
|
146
|
-
const befly: MockBefly = {
|
|
147
|
-
redis: {
|
|
148
|
-
incrWithExpire: async (key) => {
|
|
149
|
-
lastKey = key;
|
|
150
|
-
return 1;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const ctx: MockCtx = {
|
|
156
|
-
api: { name: "x" },
|
|
157
|
-
req: new Request("http://localhost/api/user/profile", { method: "POST" }),
|
|
158
|
-
route: "POST/api/user/profile",
|
|
159
|
-
ip: "2.2.2.2",
|
|
160
|
-
user: { id: 9 },
|
|
161
|
-
requestId: "rid_rate_2_ip_user",
|
|
162
|
-
corsHeaders: {}
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
166
|
-
|
|
167
|
-
expect(lastKey.includes("ip:2.2.2.2:user:9")).toBe(true);
|
|
168
|
-
expect(lastKey.startsWith("rate_limit:POST/api/user/profile:")).toBe(true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("规则优先级:精确规则优先于通配与前缀", async () => {
|
|
172
|
-
// 特意把更宽泛的规则放在前面,验证实现会优先选择更具体的规则
|
|
173
|
-
beflyConfig.rateLimit = {
|
|
174
|
-
enable: 1,
|
|
175
|
-
defaultLimit: 0,
|
|
176
|
-
defaultWindow: 0,
|
|
177
|
-
key: "ip",
|
|
178
|
-
rules: [
|
|
179
|
-
{
|
|
180
|
-
route: "*",
|
|
181
|
-
limit: 1,
|
|
182
|
-
window: 60,
|
|
183
|
-
key: "ip"
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
route: "/api/auth/*",
|
|
187
|
-
limit: 1,
|
|
188
|
-
window: 60,
|
|
189
|
-
key: "ip"
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
route: "POST/api/auth/login",
|
|
193
|
-
limit: 100,
|
|
194
|
-
window: 60,
|
|
195
|
-
key: "ip"
|
|
196
|
-
}
|
|
197
|
-
]
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
let counter = 0;
|
|
201
|
-
const befly: MockBefly = {
|
|
202
|
-
redis: {
|
|
203
|
-
incrWithExpire: async () => {
|
|
204
|
-
counter += 1;
|
|
205
|
-
return counter;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const ctx: MockCtx = {
|
|
211
|
-
api: { name: "login" },
|
|
212
|
-
req: new Request("http://localhost/api/auth/login", { method: "POST" }),
|
|
213
|
-
route: "POST/api/auth/login",
|
|
214
|
-
ip: "8.8.8.8",
|
|
215
|
-
user: {},
|
|
216
|
-
requestId: "rid_rate_priority_1",
|
|
217
|
-
corsHeaders: {}
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
221
|
-
expect(ctx.response).toBeUndefined();
|
|
222
|
-
|
|
223
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
224
|
-
expect(ctx.response).toBeUndefined();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("key=user 且缺失 userId 时回退为按 IP 计数", async () => {
|
|
228
|
-
beflyConfig.rateLimit = {
|
|
229
|
-
enable: 1,
|
|
230
|
-
defaultLimit: 0,
|
|
231
|
-
defaultWindow: 0,
|
|
232
|
-
key: "ip",
|
|
233
|
-
rules: [
|
|
234
|
-
{
|
|
235
|
-
route: "*",
|
|
236
|
-
limit: 10,
|
|
237
|
-
window: 60,
|
|
238
|
-
key: "user"
|
|
239
|
-
}
|
|
240
|
-
]
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
let lastKey = "";
|
|
244
|
-
const befly: MockBefly = {
|
|
245
|
-
redis: {
|
|
246
|
-
incrWithExpire: async (key) => {
|
|
247
|
-
lastKey = key;
|
|
248
|
-
return 1;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const ctx: MockCtx = {
|
|
254
|
-
api: { name: "x" },
|
|
255
|
-
req: new Request("http://localhost/api/user/profile", { method: "POST" }),
|
|
256
|
-
route: "POST/api/user/profile",
|
|
257
|
-
ip: "6.6.6.6",
|
|
258
|
-
user: {},
|
|
259
|
-
requestId: "rid_rate_user_fallback_ip",
|
|
260
|
-
corsHeaders: {}
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
264
|
-
|
|
265
|
-
expect(lastKey.includes("ip:6.6.6.6")).toBe(true);
|
|
266
|
-
expect(lastKey.includes("anonymous")).toBe(false);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("key=user:userId=0 不应被当作缺失", async () => {
|
|
270
|
-
beflyConfig.rateLimit = {
|
|
271
|
-
enable: 1,
|
|
272
|
-
defaultLimit: 0,
|
|
273
|
-
defaultWindow: 0,
|
|
274
|
-
key: "ip",
|
|
275
|
-
rules: [
|
|
276
|
-
{
|
|
277
|
-
route: "*",
|
|
278
|
-
limit: 10,
|
|
279
|
-
window: 60,
|
|
280
|
-
key: "user"
|
|
281
|
-
}
|
|
282
|
-
]
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
let lastKey = "";
|
|
286
|
-
const befly: MockBefly = {
|
|
287
|
-
redis: {
|
|
288
|
-
incrWithExpire: async (key) => {
|
|
289
|
-
lastKey = key;
|
|
290
|
-
return 1;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
const ctx: MockCtx = {
|
|
296
|
-
api: { name: "x" },
|
|
297
|
-
req: new Request("http://localhost/api/user/profile", { method: "POST" }),
|
|
298
|
-
route: "POST/api/user/profile",
|
|
299
|
-
ip: "7.7.7.7",
|
|
300
|
-
user: { id: 0 },
|
|
301
|
-
requestId: "rid_rate_user_id_zero",
|
|
302
|
-
corsHeaders: {}
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
306
|
-
expect(lastKey.includes(":user:0")).toBe(true);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
test("skipRoutes:命中后不计数也不拦截", async () => {
|
|
310
|
-
beflyConfig.rateLimit = {
|
|
311
|
-
enable: 1,
|
|
312
|
-
defaultLimit: 1,
|
|
313
|
-
defaultWindow: 60,
|
|
314
|
-
key: "ip",
|
|
315
|
-
skipRoutes: ["/api/health"],
|
|
316
|
-
rules: []
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
let called = 0;
|
|
320
|
-
const befly: MockBefly = {
|
|
321
|
-
redis: {
|
|
322
|
-
incrWithExpire: async () => {
|
|
323
|
-
called += 1;
|
|
324
|
-
return 999;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
const ctx: MockCtx = {
|
|
330
|
-
api: { name: "health" },
|
|
331
|
-
req: new Request("http://localhost/api/health", { method: "POST" }),
|
|
332
|
-
route: "POST/api/health",
|
|
333
|
-
ip: "9.9.9.9",
|
|
334
|
-
user: {},
|
|
335
|
-
requestId: "rid_rate_skip_1",
|
|
336
|
-
corsHeaders: {}
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
340
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
341
|
-
|
|
342
|
-
expect(called).toBe(0);
|
|
343
|
-
expect(ctx.response).toBeUndefined();
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test("route 匹配:支持 METHOD/api 前缀与精确匹配", async () => {
|
|
347
|
-
beflyConfig.rateLimit = {
|
|
348
|
-
enable: 1,
|
|
349
|
-
defaultLimit: 0,
|
|
350
|
-
defaultWindow: 0,
|
|
351
|
-
key: "ip",
|
|
352
|
-
rules: [
|
|
353
|
-
{
|
|
354
|
-
route: "POST/api/auth/*",
|
|
355
|
-
limit: 1,
|
|
356
|
-
window: 60,
|
|
357
|
-
key: "ip"
|
|
358
|
-
},
|
|
359
|
-
{
|
|
360
|
-
route: "POST/api/user/profile",
|
|
361
|
-
limit: 1,
|
|
362
|
-
window: 60,
|
|
363
|
-
key: "ip"
|
|
364
|
-
}
|
|
365
|
-
]
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
const keys: string[] = [];
|
|
369
|
-
const befly: MockBefly = {
|
|
370
|
-
redis: {
|
|
371
|
-
incrWithExpire: async (key) => {
|
|
372
|
-
keys.push(key);
|
|
373
|
-
return 1;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
const ctx1: MockCtx = {
|
|
379
|
-
api: { name: "login" },
|
|
380
|
-
req: new Request("http://localhost/api/auth/login", { method: "POST" }),
|
|
381
|
-
route: "POST/api/auth/login",
|
|
382
|
-
ip: "4.4.4.4",
|
|
383
|
-
user: {},
|
|
384
|
-
requestId: "rid_rate_match_1",
|
|
385
|
-
corsHeaders: {}
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const ctx2: MockCtx = {
|
|
389
|
-
api: { name: "profile" },
|
|
390
|
-
req: new Request("http://localhost/api/user/profile", { method: "POST" }),
|
|
391
|
-
route: "POST/api/user/profile",
|
|
392
|
-
ip: "4.4.4.4",
|
|
393
|
-
user: {},
|
|
394
|
-
requestId: "rid_rate_match_2",
|
|
395
|
-
corsHeaders: {}
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
await rateLimitHook.handler(befly as any, ctx1 as any);
|
|
399
|
-
await rateLimitHook.handler(befly as any, ctx2 as any);
|
|
400
|
-
|
|
401
|
-
expect(keys.length).toBe(2);
|
|
402
|
-
expect(keys[0].startsWith("rate_limit:POST/api/auth/login:")).toBe(true);
|
|
403
|
-
expect(keys[1].startsWith("rate_limit:POST/api/user/profile:")).toBe(true);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
test("OPTIONS 请求不计数也不拦截", async () => {
|
|
407
|
-
beflyConfig.rateLimit = {
|
|
408
|
-
enable: 1,
|
|
409
|
-
defaultLimit: 1,
|
|
410
|
-
defaultWindow: 60,
|
|
411
|
-
key: "ip",
|
|
412
|
-
rules: []
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
let called = 0;
|
|
416
|
-
const befly: MockBefly = {
|
|
417
|
-
redis: {
|
|
418
|
-
incrWithExpire: async () => {
|
|
419
|
-
called += 1;
|
|
420
|
-
return 999;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
const ctx: MockCtx = {
|
|
426
|
-
api: { name: "opt" },
|
|
427
|
-
req: new Request("http://localhost/api/user/profile", { method: "OPTIONS" }),
|
|
428
|
-
route: "OPTIONS/api/user/profile",
|
|
429
|
-
ip: "5.5.5.5",
|
|
430
|
-
user: {},
|
|
431
|
-
requestId: "rid_rate_options",
|
|
432
|
-
corsHeaders: {}
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
436
|
-
expect(called).toBe(0);
|
|
437
|
-
expect(ctx.response).toBeUndefined();
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
test("无 redis 时降级为内存计数", async () => {
|
|
441
|
-
beflyConfig.rateLimit = {
|
|
442
|
-
enable: 1,
|
|
443
|
-
defaultLimit: 0,
|
|
444
|
-
defaultWindow: 0,
|
|
445
|
-
key: "ip",
|
|
446
|
-
rules: [
|
|
447
|
-
{
|
|
448
|
-
route: "POST/api/test/memory",
|
|
449
|
-
limit: 2,
|
|
450
|
-
window: 60,
|
|
451
|
-
key: "ip"
|
|
452
|
-
}
|
|
453
|
-
]
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const befly: MockBefly = {};
|
|
457
|
-
|
|
458
|
-
const ctx: MockCtx = {
|
|
459
|
-
api: { name: "mem" },
|
|
460
|
-
req: new Request("http://localhost/api/test/memory", { method: "POST" }),
|
|
461
|
-
route: "POST/api/test/memory",
|
|
462
|
-
ip: "3.3.3.3",
|
|
463
|
-
user: {},
|
|
464
|
-
requestId: "rid_rate_3",
|
|
465
|
-
corsHeaders: {}
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
469
|
-
expect(ctx.response).toBeUndefined();
|
|
470
|
-
|
|
471
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
472
|
-
expect(ctx.response).toBeUndefined();
|
|
473
|
-
|
|
474
|
-
await rateLimitHook.handler(befly as any, ctx as any);
|
|
475
|
-
expect(ctx.response).toBeInstanceOf(Response);
|
|
476
|
-
});
|
|
477
|
-
});
|