befly 3.76.6 → 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.
Files changed (55) hide show
  1. package/Befly.js +99 -31
  2. package/apis/admin/delete.js +1 -1
  3. package/apis/admin/detail.js +1 -0
  4. package/apis/admin/insert.js +1 -1
  5. package/apis/admin/update.js +1 -1
  6. package/apis/auth/login.js +3 -2
  7. package/apis/dashboard/systemResources.js +12 -1
  8. package/apis/dict/detail.js +1 -0
  9. package/apis/dictType/detail.js +1 -0
  10. package/apis/email/config.js +1 -1
  11. package/apis/role/apiSave.js +14 -1
  12. package/apis/role/detail.js +1 -0
  13. package/apis/role/menuSave.js +1 -1
  14. package/apis/tongJi/_tongJi.js +16 -0
  15. package/apis/tongJi/dailyReport.js +5 -1
  16. package/apis/tongJi/dailyStatsDistribution.js +5 -4
  17. package/apis/tongJi/errorReport.js +8 -2
  18. package/apis/tongJi/todayOnline.js +2 -5
  19. package/checks/api.js +2 -6
  20. package/checks/field.js +30 -6
  21. package/checks/menu.js +2 -1
  22. package/configs/beflyConfig.json +3 -2
  23. package/exports.js +1 -0
  24. package/hooks/auth.js +7 -1
  25. package/hooks/permission.js +22 -1
  26. package/hooks/rateLimit.js +36 -5
  27. package/hooks/validator.js +5 -16
  28. package/index.js +44 -38
  29. package/libs/cacheHelper.js +13 -18
  30. package/libs/logger/logger.js +30 -12
  31. package/libs/memCache.js +53 -0
  32. package/libs/mysql/dbHelper.js +39 -71
  33. package/libs/mysql/dbParse.js +5 -1
  34. package/libs/mysql/sql/sqlBuilder.js +9 -0
  35. package/libs/redis/redis.js +44 -57
  36. package/libs/smtpText.js +41 -22
  37. package/libs/validator/compiler.js +1 -0
  38. package/libs/validator/parser.js +10 -4
  39. package/libs/validator/util.js +1 -5
  40. package/package.json +1 -1
  41. package/paths.js +23 -10
  42. package/router/static.js +13 -6
  43. package/schemas/api.json +4 -0
  44. package/schemas/config.json +20 -0
  45. package/sync/api.js +15 -7
  46. package/sync/dev.js +27 -3
  47. package/sync/menu.js +3 -3
  48. package/sync/syncUtil.js +12 -6
  49. package/tables/api.json +12 -0
  50. package/tables/emailLog.json +6 -2
  51. package/tables/menu.json +3 -0
  52. package/utils/is.js +7 -0
  53. package/utils/scanFiles.js +49 -46
  54. package/utils/scanSources.js +17 -0
  55. package/utils/util.js +5 -1
@@ -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
- const current = await befly.redis.incrWithExpire(key, config.defaultWindow);
42
- if (current <= config.defaultLimit) return;
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(config.defaultWindow);
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: config.defaultLimit,
52
- window: config.defaultWindow,
82
+ limit: rule.limit,
83
+ window: rule.window,
53
84
  current: current,
54
85
  apiPath: ctx.apiPath,
55
86
  key: config.key
@@ -1,6 +1,6 @@
1
1
  import { compile } from "#befly/libs/validator/index.js";
2
2
 
3
- import { getFieldSchema } from "../checks/field.js";
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
- const requiredSet = new Set(required);
21
- validate = compile({
22
- paramType: "object",
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 = Object.fromEntries(result.issues.map((issue) => [issue.path[0], `${ctx.apiFields[issue.path[0]]?.name || issue.path[0]}${issue.message}`]));
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
- const mergeStartTime = Bun.nanoseconds();
37
- const mergedConfig = deepMerge(beflyConfig, config);
38
- const mergedMenus = deepMerge(prefixMenuPaths(beflyMenus, "core"), menus);
36
+ try {
37
+ const mergeStartTime = Bun.nanoseconds();
38
+ const mergedConfig = deepMerge(beflyConfig, config);
39
+ const mergedMenus = deepMerge(prefixMenuPaths(beflyMenus, "core"), menus);
39
40
 
40
- Logger.configure({ runtimeEnv: getRunMode(), ...mergedConfig.logger });
41
- Logger.info(`启动 合并配置 耗时 ${calcPerfTime(mergeStartTime)}`);
41
+ Logger.configure({ runtimeEnv: getRunMode(), ...mergedConfig.logger });
42
+ Logger.info(`启动 合并配置 耗时 ${calcPerfTime(mergeStartTime)}`);
42
43
 
43
- let stageStartTime = Bun.nanoseconds();
44
- const configErrors = await checkConfig(mergedConfig);
45
- Logger.info(`启动 检查配置 耗时 ${calcPerfTime(stageStartTime)}`);
44
+ let stageStartTime = Bun.nanoseconds();
45
+ const configErrors = await checkConfig(mergedConfig);
46
+ Logger.info(`启动 检查配置 耗时 ${calcPerfTime(stageStartTime)}`);
46
47
 
47
- stageStartTime = Bun.nanoseconds();
48
- const { apis, tables, plugins, hooks, corns } = await scanSources({ beflyMode: mergedConfig.mysql.beflyMode });
49
- Logger.info(`启动 扫描源码 耗时 ${calcPerfTime(stageStartTime)}`);
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
- const checks = [
52
- ["配置", configErrors],
53
- ["接口", await checkApi(apis)],
54
- ["表结构", await checkTable(tables)],
55
- ["插件", await checkPlugin(plugins)],
56
- ["钩子", await checkHook(hooks)],
57
- ["定时器", checkCorn(corns)],
58
- ["菜单", await checkMenu(mergedMenus)]
59
- ];
60
- const failedChecks = checks.filter(([, errors]) => errors.length > 0).map(([check, errors]) => ({ check: check, errors: errors }));
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
- if (failedChecks.length > 0) {
63
- throw createError("检查失败:存在配置/结构问题", {
64
- code: "policy",
65
- subsystem: "checks",
66
- operation: "checkAll",
67
- errors: failedChecks
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
  }
@@ -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
- const version = await this.redis.getString(versionKey);
39
-
40
- if (!isNonEmptyString(version)) {
41
- return "";
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 非空:使用最小查询,DEL 后 SADD
178
+ * - paths 非空:DEL 后 SADD
184
179
  * @param kind - 权限类别(apis/menus)
185
180
  */
186
181
  async refreshRoleItems(kind, roleCode, paths) {
@@ -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
- this.pending = this.pending.then(async () => {
107
- await this.prepare(bytes);
108
- if (!this.stream || this.disabled) return;
109
- this.size += bytes;
110
- await new Promise((resolveWrite) => {
111
- this.stream.write(line, resolveWrite);
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
- prepareDirectory(false);
163
+ // 目录只在新建 sink 时确保一次,避免每条日志一次 mkdirSync 系统调用
158
164
  if (config.runtimeEnv === "development") {
159
- if (!appSink) appSink = new FileSink("dev", config);
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) errorSink = new FileSink("error", config);
172
+ if (!errorSink) {
173
+ prepareDirectory(false);
174
+ errorSink = new FileSink("error", config);
175
+ }
164
176
  return errorSink;
165
177
  }
166
- if (!appSink) appSink = new FileSink("app", config);
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.all(sinks.map((sink) => sink.shutdown()));
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
  };
@@ -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
+ }
@@ -78,39 +78,27 @@ function getExecuteErrorMessage(error) {
78
78
  return String(error);
79
79
  }
80
80
 
81
- const message = typeof error.message === "string" && error.message.trim().length > 0 ? error.message.trim() : typeof error.sqlMessage === "string" && error.sqlMessage.trim().length > 0 ? error.sqlMessage.trim() : "";
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
- if (typeof error.code === "string" && error.code.trim().length > 0) {
85
- detail.code = error.code;
86
- }
87
- if (typeof error.errno === "number") {
88
- detail.errno = error.errno;
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 && detailText !== "{}") {
98
+ if (message && detailText !== "{}") {
102
99
  return `${message} | ${detailText}`;
103
100
  }
104
- if (message) {
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
- if (retryOnConnectionClosed && this.isConnectionClosedError(executeError, msg)) {
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 builder = this.createSqlBuilder().selectRaw(alias).from(prepared.table).where(prepared.where);
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
- try {
517
- for (let i = 0; i < dataList.length; i++) {
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: 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);
@@ -516,7 +516,11 @@ function applyDefaultStateFilter(where = {}, table, hasLeftJoin = false, beflyMo
516
516
  return where;
517
517
  }
518
518
 
519
- const hasStateCondition = Object.keys(where).some((key) => key.startsWith("state") || key.includes(".state"));
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
  }