befly 3.76.7 → 3.77.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/Befly.js +95 -34
- package/apis/admin/delete.js +1 -1
- package/apis/admin/detail.js +1 -0
- package/apis/admin/insert.js +1 -1
- package/apis/admin/update.js +1 -1
- package/apis/auth/login.js +3 -2
- package/apis/dashboard/systemResources.js +12 -1
- package/apis/dict/detail.js +1 -0
- package/apis/dictType/detail.js +1 -0
- package/apis/email/config.js +1 -1
- package/apis/role/apiSave.js +14 -1
- package/apis/role/detail.js +1 -0
- package/apis/role/menuSave.js +1 -1
- package/apis/tongJi/_tongJi.js +16 -0
- package/apis/tongJi/dailyReport.js +5 -1
- package/apis/tongJi/dailyStatsDistribution.js +5 -4
- package/apis/tongJi/errorReport.js +8 -2
- package/apis/tongJi/todayOnline.js +2 -5
- package/checks/api.js +2 -6
- package/checks/field.js +30 -6
- package/checks/menu.js +2 -1
- package/configs/beflyConfig.json +3 -2
- package/exports.js +1 -0
- package/hooks/auth.js +7 -1
- package/hooks/permission.js +22 -1
- package/hooks/rateLimit.js +36 -5
- package/hooks/validator.js +5 -16
- package/index.js +44 -38
- package/libs/cacheHelper.js +13 -18
- package/libs/logger/logger.js +30 -12
- package/libs/memCache.js +53 -0
- package/libs/mysql/dbHelper.js +39 -71
- package/libs/mysql/dbParse.js +5 -1
- package/libs/mysql/sql/sqlBuilder.js +9 -0
- package/libs/redis/redis.js +44 -57
- package/libs/smtpText.js +41 -22
- package/libs/validator/compiler.js +1 -0
- package/libs/validator/parser.js +10 -4
- package/libs/validator/util.js +1 -5
- package/package.json +1 -1
- package/paths.js +23 -10
- package/router/static.js +13 -6
- package/schemas/api.json +4 -0
- package/schemas/config.json +20 -0
- package/sync/api.js +15 -7
- package/sync/dev.js +27 -3
- package/sync/menu.js +3 -3
- package/sync/syncUtil.js +12 -6
- package/tables/api.json +12 -0
- package/tables/emailLog.json +6 -2
- package/tables/menu.json +3 -0
- package/utils/is.js +7 -0
- package/utils/scanFiles.js +49 -46
- package/utils/scanSources.js +17 -0
- package/utils/util.js +5 -1
package/hooks/rateLimit.js
CHANGED
|
@@ -13,6 +13,27 @@ function getSkipMatchers(config) {
|
|
|
13
13
|
return matchers;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function getRuleMatchers(config) {
|
|
17
|
+
if (!Array.isArray(config.rules) || config.rules.length === 0) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
let matchers = matcherCache.get(config.rules);
|
|
21
|
+
if (!matchers) {
|
|
22
|
+
// rules: [{ path: "/api/guest/login/*", limit: 10, window: 60 }],首个匹配生效,未命中用默认值
|
|
23
|
+
matchers = config.rules.map((rule) => ({ matcher: new Bun.Glob(rule.path), limit: rule.limit, window: rule.window }));
|
|
24
|
+
matcherCache.set(config.rules, matchers);
|
|
25
|
+
}
|
|
26
|
+
return matchers;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveRateRule(config, apiPath) {
|
|
30
|
+
const hit = getRuleMatchers(config).find((rule) => rule.matcher.match(apiPath));
|
|
31
|
+
if (hit) {
|
|
32
|
+
return { limit: hit.limit, window: hit.window };
|
|
33
|
+
}
|
|
34
|
+
return { limit: config.defaultLimit, window: config.defaultWindow };
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
function resolveScope(keyType, ctx) {
|
|
17
38
|
if (keyType === "userId") return isValidPositiveInt(ctx.userId) ? `user:${ctx.userId}` : `ip:${ctx.ip}`;
|
|
18
39
|
if (keyType === "roleCode") return `role:${ctx.roleCode || "guest"}`;
|
|
@@ -37,19 +58,29 @@ export default {
|
|
|
37
58
|
}
|
|
38
59
|
|
|
39
60
|
const scope = resolveScope(config.key, ctx);
|
|
61
|
+
const rule = resolveRateRule(config, ctx.apiPath);
|
|
40
62
|
const key = `rate_limit:${ctx.apiPath}:${ctx.method}:${config.key}:${scope}`;
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
let current;
|
|
64
|
+
try {
|
|
65
|
+
current = await befly.redis.incrWithExpire(key, rule.window);
|
|
66
|
+
} catch {
|
|
67
|
+
// 计数器故障时按 failOpen 配置决定放行或拒绝,不静默失效
|
|
68
|
+
if (config.failOpen) return;
|
|
69
|
+
ctx.corsHeaders["Retry-After"] = String(rule.window);
|
|
70
|
+
ctx.response = ErrorResponse(ctx, "服务繁忙,请稍后再试", 1, null, { reason: "rate_limit_unavailable" }, "rateLimit");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (current <= rule.limit) return;
|
|
43
74
|
|
|
44
|
-
ctx.corsHeaders["Retry-After"] = String(
|
|
75
|
+
ctx.corsHeaders["Retry-After"] = String(rule.window);
|
|
45
76
|
ctx.response = ErrorResponse(
|
|
46
77
|
ctx,
|
|
47
78
|
"请求过于频繁,请稍后再试",
|
|
48
79
|
1,
|
|
49
80
|
null,
|
|
50
81
|
{
|
|
51
|
-
limit:
|
|
52
|
-
window:
|
|
82
|
+
limit: rule.limit,
|
|
83
|
+
window: rule.window,
|
|
53
84
|
current: current,
|
|
54
85
|
apiPath: ctx.apiPath,
|
|
55
86
|
key: config.key
|
package/hooks/validator.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compile } from "#befly/libs/validator/index.js";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { buildApiSchema, getFieldErrors } from "../checks/field.js";
|
|
4
4
|
import { HOOK_ORDER } from "../configs/constConfig.js";
|
|
5
5
|
import { isPlainObject } from "../utils/is.js";
|
|
6
6
|
import { ErrorResponse } from "../utils/response.js";
|
|
@@ -17,20 +17,9 @@ function getValidator(fields, required, constraints) {
|
|
|
17
17
|
const cacheKey = JSON.stringify([required, constraints]);
|
|
18
18
|
let validate = validators.get(cacheKey);
|
|
19
19
|
if (validate) return validate;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
fields: Object.fromEntries(
|
|
24
|
-
Object.entries(fields).map(([field, definition]) => [
|
|
25
|
-
field,
|
|
26
|
-
{
|
|
27
|
-
...getFieldSchema(definition),
|
|
28
|
-
optional: !requiredSet.has(field)
|
|
29
|
-
}
|
|
30
|
-
])
|
|
31
|
-
),
|
|
32
|
-
constraints: constraints
|
|
33
|
-
});
|
|
20
|
+
// 前提:fields 与 required/constraints 均来自启动期扫描的静态 apiData,
|
|
21
|
+
// 每个 fields 对象在内层 Map 只会有一个缓存条目,无需淘汰策略
|
|
22
|
+
validate = compile(buildApiSchema(fields, required, constraints));
|
|
34
23
|
validators.set(cacheKey, validate);
|
|
35
24
|
return validate;
|
|
36
25
|
}
|
|
@@ -54,7 +43,7 @@ export default {
|
|
|
54
43
|
return;
|
|
55
44
|
}
|
|
56
45
|
|
|
57
|
-
const fieldErrors =
|
|
46
|
+
const fieldErrors = getFieldErrors(result.issues, ctx.apiFields);
|
|
58
47
|
ctx.response = ErrorResponse(ctx, Object.values(fieldErrors)[0] || "参数验证失败", 1, null, fieldErrors, "validator");
|
|
59
48
|
}
|
|
60
49
|
};
|
package/index.js
CHANGED
|
@@ -33,49 +33,55 @@ function prefixMenuPaths(menus, prefix) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function createBefly(config = {}, menus = []) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
try {
|
|
37
|
+
const mergeStartTime = Bun.nanoseconds();
|
|
38
|
+
const mergedConfig = deepMerge(beflyConfig, config);
|
|
39
|
+
const mergedMenus = deepMerge(prefixMenuPaths(beflyMenus, "core"), menus);
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
Logger.configure({ runtimeEnv: getRunMode(), ...mergedConfig.logger });
|
|
42
|
+
Logger.info(`启动 合并配置 耗时 ${calcPerfTime(mergeStartTime)}`);
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
let stageStartTime = Bun.nanoseconds();
|
|
45
|
+
const configErrors = await checkConfig(mergedConfig);
|
|
46
|
+
Logger.info(`启动 检查配置 耗时 ${calcPerfTime(stageStartTime)}`);
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
stageStartTime = Bun.nanoseconds();
|
|
49
|
+
const { apis, tables, plugins, hooks, corns } = await scanSources({ beflyMode: mergedConfig.mysql.beflyMode });
|
|
50
|
+
Logger.info(`启动 扫描源码 耗时 ${calcPerfTime(stageStartTime)}`);
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
const checks = [
|
|
53
|
+
["配置", configErrors],
|
|
54
|
+
["接口", await checkApi(apis)],
|
|
55
|
+
["表结构", await checkTable(tables)],
|
|
56
|
+
["插件", await checkPlugin(plugins)],
|
|
57
|
+
["钩子", await checkHook(hooks)],
|
|
58
|
+
["定时器", checkCorn(corns)],
|
|
59
|
+
["菜单", await checkMenu(mergedMenus)]
|
|
60
|
+
];
|
|
61
|
+
const failedChecks = checks.filter(([, errors]) => errors.length > 0).map(([check, errors]) => ({ check: check, errors: errors }));
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
if (failedChecks.length > 0) {
|
|
64
|
+
throw createError("检查失败:存在配置/结构问题", {
|
|
65
|
+
code: "policy",
|
|
66
|
+
subsystem: "checks",
|
|
67
|
+
operation: "checkAll",
|
|
68
|
+
errors: failedChecks
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
mergedConfig.runMode = getRunMode();
|
|
73
|
+
return new Befly({
|
|
74
|
+
config: mergedConfig,
|
|
75
|
+
tables: tables,
|
|
76
|
+
menus: mergedMenus,
|
|
77
|
+
apis: apis,
|
|
78
|
+
hooks: hooks,
|
|
79
|
+
plugins: plugins,
|
|
80
|
+
corns: corns
|
|
68
81
|
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
Logger.error("启动失败", error);
|
|
84
|
+
await Logger.shutdown();
|
|
85
|
+
throw error;
|
|
69
86
|
}
|
|
70
|
-
|
|
71
|
-
mergedConfig.runMode = getRunMode();
|
|
72
|
-
return new Befly({
|
|
73
|
-
config: mergedConfig,
|
|
74
|
-
tables: tables,
|
|
75
|
-
menus: mergedMenus,
|
|
76
|
-
apis: apis,
|
|
77
|
-
hooks: hooks,
|
|
78
|
-
plugins: plugins,
|
|
79
|
-
corns: corns
|
|
80
|
-
});
|
|
81
87
|
}
|
package/libs/cacheHelper.js
CHANGED
|
@@ -7,6 +7,10 @@ import { Logger } from "#befly/libs/logger/index.js";
|
|
|
7
7
|
import { BEFLY_API_TABLE, BEFLY_MENU_TABLE, BEFLY_ROLE_TABLE } from "../configs/constConfig.js";
|
|
8
8
|
import { createError } from "../utils/error.js";
|
|
9
9
|
import { isNonEmptyString, isNullable, isString } from "../utils/is.js";
|
|
10
|
+
import { memRemember, memSet } from "./memCache.js";
|
|
11
|
+
|
|
12
|
+
// 角色权限版本号变化频率极低(仅启动全量重建时切换),进程内短缓存可省掉每请求一次 getString 往返
|
|
13
|
+
const ROLE_VERSION_CACHE_TTL_MS = 3000;
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* 缓存助手类
|
|
@@ -35,13 +39,10 @@ export class CacheHelper {
|
|
|
35
39
|
|
|
36
40
|
async getRoleCacheActiveVersion(kind) {
|
|
37
41
|
const versionKey = this.getRoleCacheVersionKey(kind);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return version;
|
|
42
|
+
return await memRemember(`befly:cache:${versionKey}`, ROLE_VERSION_CACHE_TTL_MS, async () => {
|
|
43
|
+
const version = await this.redis.getString(versionKey);
|
|
44
|
+
return isNonEmptyString(version) ? version : "";
|
|
45
|
+
});
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
async getRoleCacheReadKey(kind, roleCode) {
|
|
@@ -82,6 +83,7 @@ export class CacheHelper {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
await this.redis.setString(this.getRoleCacheVersionKey(kind), nextVersion);
|
|
86
|
+
memSet(`befly:cache:${this.getRoleCacheVersionKey(kind)}`, nextVersion, ROLE_VERSION_CACHE_TTL_MS);
|
|
85
87
|
|
|
86
88
|
if (!oldVersion || oldVersion === nextVersion) {
|
|
87
89
|
return;
|
|
@@ -107,18 +109,11 @@ export class CacheHelper {
|
|
|
107
109
|
*/
|
|
108
110
|
async cacheAllItems(kind, table) {
|
|
109
111
|
try {
|
|
112
|
+
// setObject 失败会直接抛错阻断启动(写操作 fail-fast),无需返回值判断
|
|
110
113
|
const items = await this.mysql.getAll({
|
|
111
114
|
table: table
|
|
112
115
|
});
|
|
113
|
-
|
|
114
|
-
const result = await this.redis.setObject(`${kind}:all`, items.data.lists);
|
|
115
|
-
|
|
116
|
-
if (result === null) {
|
|
117
|
-
Logger.warn(`⚠️ ${kind} 缓存失败`, {
|
|
118
|
-
subsystem: "cache",
|
|
119
|
-
operation: "cacheAllItems"
|
|
120
|
-
});
|
|
121
|
-
}
|
|
116
|
+
await this.redis.setObject(`${kind}:all`, items.data.lists);
|
|
122
117
|
} catch (error) {
|
|
123
118
|
throw createError(`${kind} 缓存异常`, { cause: error, code: "runtime", subsystem: "cache", operation: "cacheAllItems" });
|
|
124
119
|
}
|
|
@@ -178,9 +173,9 @@ export class CacheHelper {
|
|
|
178
173
|
}
|
|
179
174
|
|
|
180
175
|
/**
|
|
181
|
-
*
|
|
176
|
+
* 增量刷新单个角色的某类权限缓存(写入当前活跃版本 key)
|
|
182
177
|
* - paths 为空数组:仅清理缓存(防止残留)
|
|
183
|
-
* - paths
|
|
178
|
+
* - paths 非空:DEL 后 SADD
|
|
184
179
|
* @param kind - 权限类别(apis/menus)
|
|
185
180
|
*/
|
|
186
181
|
async refreshRoleItems(kind, roleCode, paths) {
|
package/libs/logger/logger.js
CHANGED
|
@@ -103,14 +103,20 @@ class FileSink {
|
|
|
103
103
|
write(line) {
|
|
104
104
|
if (this.disabled) return;
|
|
105
105
|
const bytes = textEncoder.encode(line).byteLength;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
this.stream.
|
|
106
|
+
// 链尾 catch 隔离单次写失败:保持 pending 永远 resolved,
|
|
107
|
+
// 避免未处理 rejection 与后续日志全部静默丢失
|
|
108
|
+
this.pending = this.pending
|
|
109
|
+
.then(async () => {
|
|
110
|
+
await this.prepare(bytes);
|
|
111
|
+
if (!this.stream || this.disabled) return;
|
|
112
|
+
this.size += bytes;
|
|
113
|
+
await new Promise((resolveWrite) => {
|
|
114
|
+
this.stream.write(line, resolveWrite);
|
|
115
|
+
});
|
|
116
|
+
})
|
|
117
|
+
.catch((error) => {
|
|
118
|
+
writeStderr(`写入失败 (${this.prefix}): ${error?.message || error}`);
|
|
112
119
|
});
|
|
113
|
-
});
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
async flush() {
|
|
@@ -154,16 +160,25 @@ function prepareDirectory(clearDevelopmentLog) {
|
|
|
154
160
|
}
|
|
155
161
|
|
|
156
162
|
function getSink(level) {
|
|
157
|
-
|
|
163
|
+
// 目录只在新建 sink 时确保一次,避免每条日志一次 mkdirSync 系统调用
|
|
158
164
|
if (config.runtimeEnv === "development") {
|
|
159
|
-
if (!appSink)
|
|
165
|
+
if (!appSink) {
|
|
166
|
+
prepareDirectory(false);
|
|
167
|
+
appSink = new FileSink("dev", config);
|
|
168
|
+
}
|
|
160
169
|
return appSink;
|
|
161
170
|
}
|
|
162
171
|
if (level === "error") {
|
|
163
|
-
if (!errorSink)
|
|
172
|
+
if (!errorSink) {
|
|
173
|
+
prepareDirectory(false);
|
|
174
|
+
errorSink = new FileSink("error", config);
|
|
175
|
+
}
|
|
164
176
|
return errorSink;
|
|
165
177
|
}
|
|
166
|
-
if (!appSink)
|
|
178
|
+
if (!appSink) {
|
|
179
|
+
prepareDirectory(false);
|
|
180
|
+
appSink = new FileSink("app", config);
|
|
181
|
+
}
|
|
167
182
|
return appSink;
|
|
168
183
|
}
|
|
169
184
|
|
|
@@ -241,6 +256,9 @@ export const Logger = {
|
|
|
241
256
|
const sinks = [appSink, errorSink].filter(Boolean);
|
|
242
257
|
appSink = null;
|
|
243
258
|
errorSink = null;
|
|
244
|
-
await Promise.
|
|
259
|
+
const results = await Promise.allSettled(sinks.map((sink) => sink.shutdown()));
|
|
260
|
+
for (const result of results) {
|
|
261
|
+
if (result.status === "rejected") writeStderr(`关闭失败: ${result.reason?.message || result.reason}`);
|
|
262
|
+
}
|
|
245
263
|
}
|
|
246
264
|
};
|
package/libs/memCache.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程级 TTL 缓存:存放跨请求复用的短时效数据(角色权限版本号、系统资源快照、统计项目校验等)。
|
|
3
|
+
*
|
|
4
|
+
* 边界约定:
|
|
5
|
+
* - 仅单进程内有效,多实例部署时各进程独立缓存
|
|
6
|
+
* - 适合可容忍秒级陈旧、读取代价高的数据;不适合需要强一致的数据
|
|
7
|
+
* - 无常驻定时器,过期条目在读写时惰性清理,写入超过上限时全量清淤,无内存泄漏
|
|
8
|
+
*/
|
|
9
|
+
const MAX_ENTRIES = 1000;
|
|
10
|
+
|
|
11
|
+
const store = new Map();
|
|
12
|
+
|
|
13
|
+
function pruneExpired(now) {
|
|
14
|
+
for (const [key, entry] of store) {
|
|
15
|
+
if (entry.expireAt <= now) {
|
|
16
|
+
store.delete(key);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function memGet(key) {
|
|
22
|
+
const entry = store.get(key);
|
|
23
|
+
if (!entry) return;
|
|
24
|
+
if (entry.expireAt <= Date.now()) {
|
|
25
|
+
store.delete(key);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
return entry.value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function memSet(key, value, ttlMs) {
|
|
32
|
+
if (store.size >= MAX_ENTRIES) {
|
|
33
|
+
pruneExpired(Date.now());
|
|
34
|
+
}
|
|
35
|
+
store.set(key, {
|
|
36
|
+
value: value,
|
|
37
|
+
expireAt: Date.now() + ttlMs
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function memDelete(key) {
|
|
42
|
+
store.delete(key);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function memRemember(key, ttlMs, loader) {
|
|
46
|
+
const cached = memGet(key);
|
|
47
|
+
if (cached !== undefined) {
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
const value = await loader();
|
|
51
|
+
memSet(key, value, ttlMs);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
package/libs/mysql/dbHelper.js
CHANGED
|
@@ -78,39 +78,27 @@ function getExecuteErrorMessage(error) {
|
|
|
78
78
|
return String(error);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const message =
|
|
82
|
-
const detail = {
|
|
81
|
+
const message = [error.message, error.sqlMessage].find((text) => typeof text === "string" && text.trim().length > 0)?.trim() || "";
|
|
82
|
+
const detail = {
|
|
83
|
+
code: error.code,
|
|
84
|
+
errno: error.errno,
|
|
85
|
+
sqlState: error.sqlState,
|
|
86
|
+
sqlMessage: error.sqlMessage,
|
|
87
|
+
detail: error.detail
|
|
88
|
+
};
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
if (typeof error.sqlState === "string" && error.sqlState.trim().length > 0) {
|
|
91
|
-
detail.sqlState = error.sqlState;
|
|
92
|
-
}
|
|
93
|
-
if (typeof error.sqlMessage === "string" && error.sqlMessage.trim().length > 0) {
|
|
94
|
-
detail.sqlMessage = error.sqlMessage;
|
|
95
|
-
}
|
|
96
|
-
if (typeof error.detail === "string" && error.detail.trim().length > 0) {
|
|
97
|
-
detail.detail = error.detail;
|
|
90
|
+
for (const key of Object.keys(detail)) {
|
|
91
|
+
const value = detail[key];
|
|
92
|
+
if (value === undefined || (typeof value === "string" && value.trim().length === 0)) {
|
|
93
|
+
delete detail[key];
|
|
94
|
+
}
|
|
98
95
|
}
|
|
99
96
|
|
|
100
97
|
const detailText = Object.keys(detail).length > 0 ? safeStringify(detail) : safeStringify(error);
|
|
101
|
-
if (message && detailText
|
|
98
|
+
if (message && detailText !== "{}") {
|
|
102
99
|
return `${message} | ${detailText}`;
|
|
103
100
|
}
|
|
104
|
-
|
|
105
|
-
return message;
|
|
106
|
-
}
|
|
107
|
-
return detailText;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function assertGeneratedBatchId(id, table, index) {
|
|
111
|
-
if (typeof id !== "number") {
|
|
112
|
-
throw createError(`批量插入生成 ID 失败:ids[${index}] 不是 number (table: ${table})`, { code: "runtime" });
|
|
113
|
-
}
|
|
101
|
+
return message || detailText;
|
|
114
102
|
}
|
|
115
103
|
|
|
116
104
|
function assertTimeIdValue(id) {
|
|
@@ -168,14 +156,6 @@ function assertWriteFieldsDefined(tableInfo, data, table, label) {
|
|
|
168
156
|
assertWriteFieldNamesDefined(tableInfo, Object.keys(data), table, label);
|
|
169
157
|
}
|
|
170
158
|
|
|
171
|
-
function assertNoUndefinedInRecord(row, label) {
|
|
172
|
-
for (const [key, value] of Object.entries(row)) {
|
|
173
|
-
if (value === undefined) {
|
|
174
|
-
throw validationError(`${label} 存在 undefined 字段值 (field: ${key})`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
159
|
function assertBatchInsertRowsConsistent(rows, options) {
|
|
180
160
|
if (!Array.isArray(rows)) {
|
|
181
161
|
throw validationError("批量插入 rows 必须是数组");
|
|
@@ -338,7 +318,8 @@ class DbHelper {
|
|
|
338
318
|
const duration = Date.now() - startTime;
|
|
339
319
|
const msg = getExecuteErrorMessage(executeError);
|
|
340
320
|
|
|
341
|
-
|
|
321
|
+
// 事务内连接已中断时重发单条语句可能造成半提交,直接抛出由事务回滚
|
|
322
|
+
if (retryOnConnectionClosed && !this.isTransaction && this.isConnectionClosedError(executeError, msg)) {
|
|
342
323
|
return this.execute(sql, params, false);
|
|
343
324
|
}
|
|
344
325
|
|
|
@@ -395,7 +376,8 @@ class DbHelper {
|
|
|
395
376
|
}
|
|
396
377
|
|
|
397
378
|
async fetchCount(prepared, alias) {
|
|
398
|
-
const
|
|
379
|
+
const countExpr = this.resolveCountExpr(prepared, alias);
|
|
380
|
+
const builder = this.createSqlBuilder().selectRaw(countExpr).from(prepared.table).where(prepared.where);
|
|
399
381
|
this.applyLeftJoins(builder, prepared.leftJoins);
|
|
400
382
|
const result = builder.toSelectSql();
|
|
401
383
|
const executeRes = await this.execute(result.sql, result.params);
|
|
@@ -408,6 +390,17 @@ class DbHelper {
|
|
|
408
390
|
};
|
|
409
391
|
}
|
|
410
392
|
|
|
393
|
+
resolveCountExpr(prepared, alias) {
|
|
394
|
+
if (!prepared.leftJoins || prepared.leftJoins.length === 0) {
|
|
395
|
+
return alias;
|
|
396
|
+
}
|
|
397
|
+
// leftJoin 一对多时 COUNT(*) 统计的是 join 后行数,按主表主键去重保证 total 与主表条数一致
|
|
398
|
+
const parts = prepared.table.trim().split(/\s+/);
|
|
399
|
+
const qualifier = parts.length > 1 ? parts.at(-1) : parts[0].split(".").pop();
|
|
400
|
+
const primary = snakeCase(this.getTableModel(prepared.codeTable).primary || "id");
|
|
401
|
+
return `COUNT(DISTINCT ${quoteIdentMySql(qualifier)}.${quoteIdentMySql(primary)}) as total`;
|
|
402
|
+
}
|
|
403
|
+
|
|
411
404
|
normalizeRowData(row, tableInfo = {}) {
|
|
412
405
|
if (!row) {
|
|
413
406
|
return {};
|
|
@@ -512,38 +505,16 @@ class DbHelper {
|
|
|
512
505
|
};
|
|
513
506
|
}
|
|
514
507
|
|
|
515
|
-
const ids =
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
ids.push(genId());
|
|
519
|
-
}
|
|
520
|
-
} catch (error) {
|
|
521
|
-
if (dataList.length === 1) {
|
|
522
|
-
throw createError(`生成 ID 失败 (table: ${table})`, {
|
|
523
|
-
cause: error,
|
|
524
|
-
code: "runtime",
|
|
525
|
-
subsystem: "db",
|
|
526
|
-
operation: "genId",
|
|
527
|
-
table: table
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
throw error;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const processedList = dataList.map((data, index) => {
|
|
534
|
-
const id = ids[index];
|
|
535
|
-
if (dataList.length > 1) {
|
|
536
|
-
assertGeneratedBatchId(id, snakeTable, index);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
return this.buildInsertRow({
|
|
508
|
+
const ids = dataList.map(() => genId());
|
|
509
|
+
const processedList = dataList.map((data, index) =>
|
|
510
|
+
this.buildInsertRow({
|
|
540
511
|
data: data,
|
|
541
|
-
id:
|
|
512
|
+
id: ids[index],
|
|
542
513
|
now: now,
|
|
543
514
|
beflyMode: tableMode,
|
|
544
515
|
tableInfo: tableInfo
|
|
545
|
-
})
|
|
546
|
-
|
|
516
|
+
})
|
|
517
|
+
);
|
|
547
518
|
|
|
548
519
|
return {
|
|
549
520
|
ids: ids,
|
|
@@ -715,7 +686,9 @@ class DbHelper {
|
|
|
715
686
|
return {
|
|
716
687
|
data: {
|
|
717
688
|
lists: lists,
|
|
718
|
-
total: total
|
|
689
|
+
total: total,
|
|
690
|
+
// 达到硬上限被截断时显式告知调用方,避免拿到不完整数据无感知
|
|
691
|
+
truncated: result.length >= MAX_LIMIT
|
|
719
692
|
},
|
|
720
693
|
sql: {
|
|
721
694
|
count: countResult.sql,
|
|
@@ -743,12 +716,8 @@ class DbHelper {
|
|
|
743
716
|
const now = Date.now();
|
|
744
717
|
const insertRows = await this.createInsertRows(table, snakeTable, [data], now);
|
|
745
718
|
const processed = insertRows.processedList[0];
|
|
746
|
-
|
|
747
|
-
assertWriteDataHasFields(processed, "插入数据必须至少有一个字段", snakeTable);
|
|
748
719
|
assertWriteFieldsDefined(tableInfo, processed, table, "insData.data");
|
|
749
720
|
|
|
750
|
-
assertNoUndefinedInRecord(processed, `insData 插入数据 (table: ${snakeTable})`);
|
|
751
|
-
|
|
752
721
|
const builder = this.createSqlBuilder();
|
|
753
722
|
const { sql, params } = builder.toInsertSql(snakeTable, processed);
|
|
754
723
|
const executeRes = await this.execute(sql, params);
|
|
@@ -909,7 +878,6 @@ class DbHelper {
|
|
|
909
878
|
beflyMode: this.getTableMode(parsed.table),
|
|
910
879
|
tableInfo: tableInfo
|
|
911
880
|
});
|
|
912
|
-
assertWriteDataHasFields(processed, "更新数据必须至少有一个字段", parsed.snakeTable);
|
|
913
881
|
assertWriteFieldsDefined(tableInfo, processed, parsed.table, "updData.data");
|
|
914
882
|
const builder = this.createSqlBuilder().where(parsed.where);
|
|
915
883
|
const { sql, params } = builder.toUpdateSql(parsed.snakeTable, processed);
|
package/libs/mysql/dbParse.js
CHANGED
|
@@ -516,7 +516,11 @@ function applyDefaultStateFilter(where = {}, table, hasLeftJoin = false, beflyMo
|
|
|
516
516
|
return where;
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
-
|
|
519
|
+
// 剥离 $op / $or 后缀后精确匹配,避免 stateCode 等业务字段误判为已有 state 条件
|
|
520
|
+
const hasStateCondition = Object.keys(where).some((key) => {
|
|
521
|
+
const field = key.split("$")[0];
|
|
522
|
+
return field === "state" || field.endsWith(".state");
|
|
523
|
+
});
|
|
520
524
|
if (hasStateCondition) {
|
|
521
525
|
return where;
|
|
522
526
|
}
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
|
|
27
27
|
import { escapeField, escapeTable, resolveQuoteIdent } from "./identifier.js";
|
|
28
28
|
|
|
29
|
+
function assertSafeLimit(value) {
|
|
30
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
31
|
+
throw new Error(`LIMIT/OFFSET 必须是非负整数 (value: ${String(value)})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* SQL 构建器类
|
|
31
37
|
*/
|
|
@@ -290,8 +296,11 @@ export class SqlBuilder {
|
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
if (this.queryModel.limit !== null) {
|
|
299
|
+
// LIMIT/OFFSET 直接内插,必须在此断言整型,不信任调用方已校验
|
|
300
|
+
assertSafeLimit(this.queryModel.limit);
|
|
293
301
|
sql += ` LIMIT ${this.queryModel.limit}`;
|
|
294
302
|
if (this.queryModel.offset !== null && this.queryModel.offset !== undefined) {
|
|
303
|
+
assertSafeLimit(this.queryModel.offset);
|
|
295
304
|
sql += ` OFFSET ${this.queryModel.offset}`;
|
|
296
305
|
}
|
|
297
306
|
}
|