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.
- package/README.md +47 -19
- 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 +17 -11
- package/docs/api/api.md +16 -2
- package/docs/guide/quickstart.md +31 -10
- package/docs/hooks/hook.md +2 -2
- package/docs/hooks/rateLimit.md +1 -1
- package/docs/infra/redis.md +26 -14
- package/docs/plugins/plugin.md +23 -21
- package/docs/quickstart.md +5 -328
- package/docs/reference/addon.md +0 -4
- package/docs/reference/config.md +14 -31
- package/docs/reference/logger.md +3 -3
- package/docs/reference/sync.md +132 -237
- package/docs/reference/table.md +28 -30
- 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/docs/cipher.md +0 -582
- package/docs/database.md +0 -1176
- 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/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
|
-
}
|
package/sync/syncDb/constants.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* syncDb 常量定义模块
|
|
3
|
-
*
|
|
4
|
-
* 包含:
|
|
5
|
-
* - 数据库类型判断
|
|
6
|
-
* - 版本要求
|
|
7
|
-
* - 数据类型映射
|
|
8
|
-
* - 系统字段定义
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* 数据库版本要求
|
|
13
|
-
*/
|
|
14
|
-
export const DB_VERSION_REQUIREMENTS = {
|
|
15
|
-
MYSQL_MIN_MAJOR: 8,
|
|
16
|
-
POSTGRES_MIN_MAJOR: 17,
|
|
17
|
-
SQLITE_MIN_VERSION: "3.50.0",
|
|
18
|
-
SQLITE_MIN_VERSION_NUM: 35000 // 3 * 10000 + 50 * 100
|
|
19
|
-
} as const;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 需要创建索引的系统字段
|
|
23
|
-
*/
|
|
24
|
-
export const SYSTEM_INDEX_FIELDS = ["created_at", "updated_at", "state"] as const;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 字段变更类型的中文标签映射
|
|
28
|
-
*/
|
|
29
|
-
export const CHANGE_TYPE_LABELS = {
|
|
30
|
-
length: "长度",
|
|
31
|
-
datatype: "类型",
|
|
32
|
-
comment: "注释",
|
|
33
|
-
default: "默认值",
|
|
34
|
-
nullable: "可空约束",
|
|
35
|
-
unique: "唯一约束"
|
|
36
|
-
} as const;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* MySQL 表配置
|
|
40
|
-
*
|
|
41
|
-
* 固定配置说明:
|
|
42
|
-
* - ENGINE: InnoDB(支持事务、外键)
|
|
43
|
-
* - CHARSET: utf8mb4(完整 Unicode 支持,包括 Emoji)
|
|
44
|
-
* - COLLATE: utf8mb4_0900_ai_ci(MySQL 8.0 推荐,不区分重音和大小写)
|
|
45
|
-
*/
|
|
46
|
-
export const MYSQL_TABLE_CONFIG = {
|
|
47
|
-
ENGINE: "InnoDB",
|
|
48
|
-
CHARSET: "utf8mb4",
|
|
49
|
-
COLLATE: "utf8mb4_0900_ai_ci"
|
|
50
|
-
} as const;
|
|
51
|
-
|
|
52
|
-
// 是否为计划模式(仅输出 SQL 不执行)
|
|
53
|
-
export const IS_PLAN = process.argv.includes("--plan");
|
|
54
|
-
|
|
55
|
-
// 数据库类型(运行时设置,默认 mysql)
|
|
56
|
-
let _dbType: string = "mysql";
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* 设置数据库类型(由 syncDbCommand 调用)
|
|
60
|
-
* @param dbType - 数据库类型(mysql/postgresql/postgres/sqlite)
|
|
61
|
-
*/
|
|
62
|
-
export function setDbType(dbType: string): void {
|
|
63
|
-
_dbType = (dbType || "mysql").toLowerCase();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 获取当前数据库类型
|
|
68
|
-
*/
|
|
69
|
-
export function getDbType(): string {
|
|
70
|
-
return _dbType;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 数据库类型判断(getter 函数,运行时动态计算)
|
|
74
|
-
export function isMySQL(): boolean {
|
|
75
|
-
return _dbType === "mysql";
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function isPG(): boolean {
|
|
79
|
-
return _dbType === "postgresql" || _dbType === "postgres";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function isSQLite(): boolean {
|
|
83
|
-
return _dbType === "sqlite";
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 兼容旧代码的静态别名(通过 getter 实现动态获取)
|
|
87
|
-
export const DB_TYPE = {
|
|
88
|
-
get current(): string {
|
|
89
|
-
return _dbType;
|
|
90
|
-
},
|
|
91
|
-
get IS_MYSQL(): boolean {
|
|
92
|
-
return isMySQL();
|
|
93
|
-
},
|
|
94
|
-
get IS_PG(): boolean {
|
|
95
|
-
return isPG();
|
|
96
|
-
},
|
|
97
|
-
get IS_SQLITE(): boolean {
|
|
98
|
-
return isSQLite();
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 获取字段类型映射(根据当前数据库类型)
|
|
104
|
-
*/
|
|
105
|
-
export function getTypeMapping(): Record<string, string> {
|
|
106
|
-
const isSqlite = isSQLite();
|
|
107
|
-
const isPg = isPG();
|
|
108
|
-
const isMysql = isMySQL();
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
number: isSqlite ? "INTEGER" : isPg ? "BIGINT" : "BIGINT",
|
|
112
|
-
string: isSqlite ? "TEXT" : isPg ? "character varying" : "VARCHAR",
|
|
113
|
-
text: isMysql ? "MEDIUMTEXT" : "TEXT",
|
|
114
|
-
array_string: isSqlite ? "TEXT" : isPg ? "character varying" : "VARCHAR",
|
|
115
|
-
array_text: isMysql ? "MEDIUMTEXT" : "TEXT",
|
|
116
|
-
array_number_string: isSqlite ? "TEXT" : isPg ? "character varying" : "VARCHAR",
|
|
117
|
-
array_number_text: isMysql ? "MEDIUMTEXT" : "TEXT"
|
|
118
|
-
};
|
|
119
|
-
}
|