befly 3.76.7 → 3.77.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.
Files changed (72) hide show
  1. package/Befly.js +97 -34
  2. package/apis/admin/_meta.js +3 -0
  3. package/apis/admin/delete.js +1 -1
  4. package/apis/admin/detail.js +1 -0
  5. package/apis/admin/insert.js +1 -1
  6. package/apis/admin/update.js +1 -1
  7. package/apis/api/_meta.js +3 -0
  8. package/apis/auth/_meta.js +3 -0
  9. package/apis/auth/login.js +3 -2
  10. package/apis/dashboard/_meta.js +3 -0
  11. package/apis/dashboard/systemResources.js +12 -1
  12. package/apis/dict/_meta.js +3 -0
  13. package/apis/dict/detail.js +1 -0
  14. package/apis/dictType/_meta.js +3 -0
  15. package/apis/dictType/detail.js +1 -0
  16. package/apis/email/_meta.js +3 -0
  17. package/apis/email/config.js +1 -1
  18. package/apis/loginLog/_meta.js +3 -0
  19. package/apis/menu/_meta.js +3 -0
  20. package/apis/operateLog/_meta.js +3 -0
  21. package/apis/role/_meta.js +3 -0
  22. package/apis/role/apiSave.js +14 -1
  23. package/apis/role/detail.js +1 -0
  24. package/apis/role/menuSave.js +1 -1
  25. package/apis/source/_meta.js +3 -0
  26. package/apis/tongJi/_meta.js +3 -0
  27. package/apis/tongJi/_tongJi.js +16 -0
  28. package/apis/tongJi/dailyReport.js +5 -1
  29. package/apis/tongJi/dailyStatsDistribution.js +5 -4
  30. package/apis/tongJi/errorReport.js +8 -2
  31. package/apis/tongJi/todayOnline.js +2 -5
  32. package/apis/upload/_meta.js +3 -0
  33. package/checks/api.js +2 -6
  34. package/checks/field.js +30 -6
  35. package/checks/menu.js +2 -1
  36. package/checks/table.js +1 -1
  37. package/configs/beflyConfig.json +7 -3
  38. package/exports.js +1 -0
  39. package/hooks/auth.js +7 -1
  40. package/hooks/permission.js +22 -1
  41. package/hooks/rateLimit.js +36 -5
  42. package/hooks/validator.js +5 -16
  43. package/index.js +46 -38
  44. package/libs/cacheHelper.js +13 -18
  45. package/libs/logger/logger.js +103 -24
  46. package/libs/logger/sanitize.js +40 -15
  47. package/libs/memCache.js +53 -0
  48. package/libs/mysql/dbHelper.js +39 -71
  49. package/libs/mysql/dbParse.js +5 -1
  50. package/libs/mysql/sql/sqlBuilder.js +9 -0
  51. package/libs/redis/redis.js +44 -57
  52. package/libs/smtpText.js +41 -22
  53. package/libs/validator/compiler.js +3 -2
  54. package/libs/validator/parser.js +11 -5
  55. package/libs/validator/util.js +1 -5
  56. package/package.json +1 -1
  57. package/paths.js +23 -10
  58. package/router/static.js +13 -6
  59. package/schemas/api.json +10 -0
  60. package/schemas/config.json +38 -0
  61. package/sync/api.js +18 -7
  62. package/sync/dev.js +27 -3
  63. package/sync/menu.js +3 -3
  64. package/sync/syncUtil.js +12 -6
  65. package/tables/api.json +21 -0
  66. package/tables/emailLog.json +6 -2
  67. package/tables/menu.json +3 -0
  68. package/utils/is.js +7 -0
  69. package/utils/prettyError.js +102 -0
  70. package/utils/scanFiles.js +85 -46
  71. package/utils/scanSources.js +56 -4
  72. package/utils/util.js +5 -1
@@ -44,17 +44,23 @@ function isSensitiveKey(key, matcher) {
44
44
  return matcher.contains.some((part) => lower.includes(part));
45
45
  }
46
46
 
47
+ // 长字符串截断:超过阈值保留前段并追加统一可 grep 的标记
48
+ function truncateText(text, limit) {
49
+ if (text.length <= limit) return text;
50
+ return `${text.slice(0, limit)}[截断,原 ${text.length} 字符]`;
51
+ }
52
+
47
53
  function sanitizeError(error, options, state, depth, visited) {
48
54
  if (visited.has(error)) return "[Circular]";
49
55
  visited.add(error);
50
56
 
51
57
  const output = {
52
58
  name: error.name || "Error",
53
- message: error.message || "",
54
- chain: errorChainSummary(error)
59
+ message: truncateText(error.message || "", options.truncateStringLength),
60
+ chain: truncateText(errorChainSummary(error), options.truncateStringLength)
55
61
  };
56
62
 
57
- if (typeof error.stack === "string") output.stack = error.stack;
63
+ if (typeof error.stack === "string") output.stack = truncateText(error.stack, options.truncateStringLength);
58
64
  if (error.cause !== undefined) output.cause = sanitizeValue(error.cause, options, state, depth + 1, visited);
59
65
 
60
66
  for (const key of Object.keys(error)) {
@@ -65,10 +71,11 @@ function sanitizeError(error, options, state, depth, visited) {
65
71
  return output;
66
72
  }
67
73
 
74
+ // 降级预览:整体转字符串并按预览长度截断(深度/节点数超限、非普通对象的统一出口)
68
75
  function stringifyPreview(value, options) {
69
76
  const seen = new WeakSet();
70
77
  try {
71
- return JSON.stringify(value, (key, item) => {
78
+ const text = JSON.stringify(value, (key, item) => {
72
79
  if (key && isSensitiveKey(key, options.sensitiveKeyMatcher)) return "[MASKED]";
73
80
  if (typeof item === "bigint") return String(item);
74
81
  if (item && typeof item === "object") {
@@ -77,17 +84,41 @@ function stringifyPreview(value, options) {
77
84
  }
78
85
  return item;
79
86
  });
87
+ return truncateText(text, options.truncatePreviewLength);
80
88
  } catch {
81
89
  try {
82
- return String(value);
90
+ return truncateText(String(value), options.truncatePreviewLength);
83
91
  } catch {
84
- return "[Unserializable]";
92
+ return "[无法序列化]";
85
93
  }
86
94
  }
87
95
  }
88
96
 
97
+ function sanitizeArray(value, options, state, depth, visited) {
98
+ const limit = options.truncateArrayLength;
99
+ const items = value.slice(0, limit).map((item) => sanitizeValue(item, options, state, depth + 1, visited));
100
+ if (value.length > limit) {
101
+ items.push(`[截断,显示 ${limit}/共 ${value.length} 项]`);
102
+ }
103
+ return items;
104
+ }
105
+
106
+ function sanitizeObject(value, options, state, depth, visited) {
107
+ const entries = Object.entries(value);
108
+ const shown = entries.slice(0, options.sanitizeObjectKeys);
109
+ const output = {};
110
+ for (const [key, item] of shown) {
111
+ output[key] = isSensitiveKey(key, options.sensitiveKeyMatcher) ? "[MASKED]" : sanitizeValue(item, options, state, depth + 1, visited);
112
+ }
113
+ if (entries.length > shown.length) {
114
+ output.__truncated__ = `已省略 ${entries.length - shown.length} 个键`;
115
+ }
116
+ return output;
117
+ }
118
+
89
119
  function sanitizeValue(value, options, state, depth, visited) {
90
- if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
120
+ if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") return value;
121
+ if (typeof value === "string") return truncateText(value, options.truncateStringLength);
91
122
  if (typeof value === "bigint") return String(value);
92
123
  if (value instanceof Error) return sanitizeError(value, options, state, depth, visited);
93
124
 
@@ -98,14 +129,8 @@ function sanitizeValue(value, options, state, depth, visited) {
98
129
  visited.add(value);
99
130
  state.nodes += 1;
100
131
 
101
- if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, options, state, depth + 1, visited));
102
-
103
- const output = {};
104
- const entries = Object.entries(value).slice(0, options.sanitizeObjectKeys);
105
- for (const [key, item] of entries) {
106
- output[key] = isSensitiveKey(key, options.sensitiveKeyMatcher) ? "[MASKED]" : sanitizeValue(item, options, state, depth + 1, visited);
107
- }
108
- return output;
132
+ if (Array.isArray(value)) return sanitizeArray(value, options, state, depth, visited);
133
+ return sanitizeObject(value, options, state, depth, visited);
109
134
  }
110
135
 
111
136
  export function sanitizeLogRecord(record, options) {
@@ -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
  }
@@ -14,7 +14,9 @@ function buildRedisUrl(config) {
14
14
  }
15
15
 
16
16
  /**
17
- * Bun RedisClient 高层操作封装。除 ping 外,操作失败记录日志并返回约定 fallback。
17
+ * Bun RedisClient 高层操作封装。错误策略分级:
18
+ * - 读操作失败记录日志并返回约定 fallback,权限等判断按无数据处理(fail-closed)。
19
+ * - 写操作与计数器失败记录日志后抛出,由调用方决定降级策略,避免故障被静默吞掉。
18
20
  */
19
21
  export class RedisHelper {
20
22
  constructor(options) {
@@ -31,16 +33,21 @@ export class RedisHelper {
31
33
  }
32
34
  }
33
35
 
36
+ async strictCall(label, operation) {
37
+ try {
38
+ return await operation();
39
+ } catch (error) {
40
+ Logger.error(`Redis ${label} 错误`, error);
41
+ throw error;
42
+ }
43
+ }
44
+
34
45
  async setObject(key, obj, ttl = null) {
35
- return this.safeCall(
36
- "setObject",
37
- async () => {
38
- const data = JSON.stringify(obj);
39
- const pkey = `${this.prefix}${key}`;
40
- return ttl ? this.client.setex(pkey, ttl, data) : this.client.set(pkey, data);
41
- },
42
- null
43
- );
46
+ return this.strictCall("setObject", () => {
47
+ const data = JSON.stringify(obj);
48
+ const pkey = `${this.prefix}${key}`;
49
+ return ttl ? this.client.setex(pkey, ttl, data) : this.client.set(pkey, data);
50
+ });
44
51
  }
45
52
 
46
53
  async getObject(key) {
@@ -55,20 +62,16 @@ export class RedisHelper {
55
62
  }
56
63
 
57
64
  async delObject(key) {
58
- return this.safeCall("delObject", async () => {
65
+ return this.strictCall("delObject", async () => {
59
66
  await this.client.del(`${this.prefix}${key}`);
60
67
  });
61
68
  }
62
69
 
63
70
  async setString(key, value, ttl = null) {
64
- return this.safeCall(
65
- "setString",
66
- async () => {
67
- const pkey = `${this.prefix}${key}`;
68
- return ttl ? this.client.setex(pkey, ttl, value) : this.client.set(pkey, value);
69
- },
70
- null
71
- );
71
+ return this.strictCall("setString", () => {
72
+ const pkey = `${this.prefix}${key}`;
73
+ return ttl ? this.client.setex(pkey, ttl, value) : this.client.set(pkey, value);
74
+ });
72
75
  }
73
76
 
74
77
  async getString(key) {
@@ -84,20 +87,16 @@ export class RedisHelper {
84
87
  }
85
88
 
86
89
  async incrWithExpire(key, seconds) {
87
- return this.safeCall(
88
- "incrWithExpire",
89
- async () => {
90
- const pkey = `${this.prefix}${key}`;
91
- const result = await this.client.incr(pkey);
92
- if (result === 1) await this.client.expire(pkey, seconds);
93
- return result;
94
- },
95
- 0
96
- );
90
+ // SET NX EX 先建键并带 TTL,再 INCR,保证计数键自创建起必有过期时间
91
+ return this.strictCall("incrWithExpire", async () => {
92
+ const pkey = `${this.prefix}${key}`;
93
+ await this.client.send("SET", [pkey, "0", "EX", String(seconds), "NX"]);
94
+ return this.client.incr(pkey);
95
+ });
97
96
  }
98
97
 
99
98
  async expire(key, seconds) {
100
- return this.safeCall("expire", () => this.client.expire(`${this.prefix}${key}`, seconds), 0);
99
+ return this.strictCall("expire", () => this.client.expire(`${this.prefix}${key}`, seconds));
101
100
  }
102
101
 
103
102
  async ttl(key) {
@@ -105,14 +104,10 @@ export class RedisHelper {
105
104
  }
106
105
 
107
106
  async sadd(key, members) {
108
- return this.safeCall(
109
- "sadd",
110
- async () => {
111
- if (members.length === 0) return 0;
112
- return this.client.sadd(`${this.prefix}${key}`, ...members);
113
- },
114
- 0
115
- );
107
+ return this.strictCall("sadd", async () => {
108
+ if (members.length === 0) return 0;
109
+ return this.client.sadd(`${this.prefix}${key}`, ...members);
110
+ });
116
111
  }
117
112
 
118
113
  async sismember(key, member) {
@@ -128,31 +123,23 @@ export class RedisHelper {
128
123
  }
129
124
 
130
125
  async saddBatch(items) {
131
- return this.safeCall(
132
- "saddBatch",
133
- async () => {
134
- if (items.length === 0) return 0;
135
- const results = await Promise.all(items.map((item) => this.sadd(item.key, item.members)));
136
- return results.reduce((sum, count) => sum + count, 0);
137
- },
138
- 0
139
- );
126
+ return this.strictCall("saddBatch", async () => {
127
+ if (items.length === 0) return 0;
128
+ const results = await Promise.all(items.map((item) => this.sadd(item.key, item.members)));
129
+ return results.reduce((sum, count) => sum + count, 0);
130
+ });
140
131
  }
141
132
 
142
133
  async del(key) {
143
- return this.safeCall("del", () => this.client.del(`${this.prefix}${key}`), 0);
134
+ return this.strictCall("del", () => this.client.del(`${this.prefix}${key}`));
144
135
  }
145
136
 
146
137
  async delBatch(keys) {
147
- return this.safeCall(
148
- "delBatch",
149
- async () => {
150
- if (keys.length === 0) return 0;
151
- const results = await Promise.all(keys.map((key) => this.client.del(`${this.prefix}${key}`)));
152
- return results.reduce((sum, count) => sum + count, 0);
153
- },
154
- 0
155
- );
138
+ return this.strictCall("delBatch", async () => {
139
+ if (keys.length === 0) return 0;
140
+ const results = await Promise.all(keys.map((key) => this.client.del(`${this.prefix}${key}`)));
141
+ return results.reduce((sum, count) => sum + count, 0);
142
+ });
156
143
  }
157
144
 
158
145
  async info(section) {
package/libs/smtpText.js CHANGED
@@ -30,7 +30,9 @@ function parseAddressList(value) {
30
30
  .map(function (item) {
31
31
  const trimmed = item.trim();
32
32
  const matched = /<([^<>]+)>/.exec(trimmed);
33
- return matched ? matched[1].trim() : trimmed;
33
+ // 收件人最终拼入 RCPT TO 命令,必须剥离 CR/LF 防 SMTP 命令注入
34
+ const address = matched ? matched[1].trim() : trimmed;
35
+ return address.replace(/[\r\n]+/g, "");
34
36
  })
35
37
  .filter(function (item) {
36
38
  return item.length > 0;
@@ -198,6 +200,20 @@ function writeCommand(socket, command) {
198
200
  socket.write(`${command}\r\n`);
199
201
  }
200
202
 
203
+ // 连接建立阶段默认 10 秒超时,避免网络黑洞下 sendMail 长时间挂起
204
+ function connectWithTimeout(options, timeoutMs) {
205
+ let timer;
206
+ const connectPromise = Bun.connect(options);
207
+ const timeoutPromise = new Promise((_, reject) => {
208
+ timer = setTimeout(() => {
209
+ reject(createError(`SMTP 连接超时(${timeoutMs}ms):${options.hostname}:${options.port}`, { code: "runtime", subsystem: "smtp", operation: "connect" }));
210
+ // 超时后才建立的连接直接关闭,避免孤儿 socket
211
+ connectPromise.then((late) => late.end()).catch(() => {});
212
+ }, timeoutMs);
213
+ });
214
+ return Promise.race([connectPromise, timeoutPromise]).finally(() => clearTimeout(timer));
215
+ }
216
+
201
217
  export async function sendSmtpTextMail(config, options) {
202
218
  const mail = createSmtpTextMessage(config, options);
203
219
  if (mail.recipients.length === 0) {
@@ -205,28 +221,31 @@ export async function sendSmtpTextMail(config, options) {
205
221
  }
206
222
 
207
223
  const reader = createResponseReader();
208
- const socket = await Bun.connect({
209
- hostname: config.host,
210
- port: config.port || 25,
211
- tls: normalizeSecureValue(config.secure),
212
- socket: {
213
- data: function (_socket, data) {
214
- reader.append(data);
215
- },
216
- close: function (_socket, error) {
217
- reader.close(error || null);
218
- },
219
- error: function (_socket, error) {
220
- reader.close(error);
221
- },
222
- connectError: function (_socket, error) {
223
- reader.close(error);
224
- },
225
- end: function () {
226
- reader.close(null);
224
+ const socket = await connectWithTimeout(
225
+ {
226
+ hostname: config.host,
227
+ port: config.port || 25,
228
+ tls: normalizeSecureValue(config.secure),
229
+ socket: {
230
+ data: function (_socket, data) {
231
+ reader.append(data);
232
+ },
233
+ close: function (_socket, error) {
234
+ reader.close(error || null);
235
+ },
236
+ error: function (_socket, error) {
237
+ reader.close(error);
238
+ },
239
+ connectError: function (_socket, error) {
240
+ reader.close(error);
241
+ },
242
+ end: function () {
243
+ reader.close(null);
244
+ }
227
245
  }
228
- }
229
- });
246
+ },
247
+ 10000
248
+ );
230
249
 
231
250
  try {
232
251
  const timeout = 10000;
@@ -23,7 +23,7 @@ function schemaError(path, message) {
23
23
  }
24
24
 
25
25
  function assertBoolean(value, path) {
26
- if (value !== undefined && typeof value !== "boolean") schemaError(path, "必须是 boolean");
26
+ if (value !== undefined && typeof value !== "boolean") schemaError(path, "必须是布尔值");
27
27
  }
28
28
 
29
29
  function assertLimit(value, path) {
@@ -200,6 +200,7 @@ export function compileNode(schema, path, parents) {
200
200
  node.maxItem = schema.maxItem;
201
201
  node.unique = schema.unique === true;
202
202
  node.items = schema.items === undefined ? null : compileNode(schema.items, [...path, "items"], parents);
203
+ if (node.unique && node.items && node.items.kind === "object") schemaError([...path, "unique"], "对象数组判重请使用 uniqueBy,unique 仅对标量数组有效");
203
204
  if (schema.uniqueBy !== undefined) {
204
205
  if (typeof schema.uniqueBy !== "string" || !schema.uniqueBy || schema.uniqueBy !== schema.uniqueBy.trim()) schemaError([...path, "uniqueBy"], "必须是非空无首尾空白字符串");
205
206
  if (!node.items || node.items.kind !== "object" || !node.items.fieldSet.has(schema.uniqueBy)) schemaError([...path, "uniqueBy"], "必须引用 items.fields 中的字段");
@@ -211,7 +212,7 @@ export function compileNode(schema, path, parents) {
211
212
  if (schema.paramType === "object") {
212
213
  assertItemBounds(schema, path);
213
214
  if (schema.fields !== undefined && !isPlainObject(schema.fields)) schemaError([...path, "fields"], "必须是对象");
214
- if (schema.extra !== undefined && typeof schema.extra !== "boolean" && !isPlainObject(schema.extra)) schemaError([...path, "extra"], "必须是 boolean 或 Schema");
215
+ if (schema.extra !== undefined && typeof schema.extra !== "boolean" && !isPlainObject(schema.extra)) schemaError([...path, "extra"], "必须是布尔值或 Schema 对象");
215
216
  node.minItem = schema.minItem;
216
217
  node.maxItem = schema.maxItem;
217
218
  node.fields = Object.entries(schema.fields || {}).map(([key, child]) => ({ key: key, node: compileNode(child, [...path, "fields", key], parents) }));