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.
- package/Befly.js +97 -34
- package/apis/admin/_meta.js +3 -0
- 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/api/_meta.js +3 -0
- package/apis/auth/_meta.js +3 -0
- package/apis/auth/login.js +3 -2
- package/apis/dashboard/_meta.js +3 -0
- package/apis/dashboard/systemResources.js +12 -1
- package/apis/dict/_meta.js +3 -0
- package/apis/dict/detail.js +1 -0
- package/apis/dictType/_meta.js +3 -0
- package/apis/dictType/detail.js +1 -0
- package/apis/email/_meta.js +3 -0
- package/apis/email/config.js +1 -1
- package/apis/loginLog/_meta.js +3 -0
- package/apis/menu/_meta.js +3 -0
- package/apis/operateLog/_meta.js +3 -0
- package/apis/role/_meta.js +3 -0
- package/apis/role/apiSave.js +14 -1
- package/apis/role/detail.js +1 -0
- package/apis/role/menuSave.js +1 -1
- package/apis/source/_meta.js +3 -0
- package/apis/tongJi/_meta.js +3 -0
- 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/apis/upload/_meta.js +3 -0
- package/checks/api.js +2 -6
- package/checks/field.js +30 -6
- package/checks/menu.js +2 -1
- package/checks/table.js +1 -1
- package/configs/beflyConfig.json +7 -3
- 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 +46 -38
- package/libs/cacheHelper.js +13 -18
- package/libs/logger/logger.js +103 -24
- package/libs/logger/sanitize.js +40 -15
- 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 +3 -2
- package/libs/validator/parser.js +11 -5
- 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 +10 -0
- package/schemas/config.json +38 -0
- package/sync/api.js +18 -7
- package/sync/dev.js +27 -3
- package/sync/menu.js +3 -3
- package/sync/syncUtil.js +12 -6
- package/tables/api.json +21 -0
- package/tables/emailLog.json +6 -2
- package/tables/menu.json +3 -0
- package/utils/is.js +7 -0
- package/utils/prettyError.js +102 -0
- package/utils/scanFiles.js +85 -46
- package/utils/scanSources.js +56 -4
- package/utils/util.js +5 -1
package/libs/logger/sanitize.js
CHANGED
|
@@ -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
|
-
|
|
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 "[
|
|
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 === "
|
|
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
|
|
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) {
|
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
|
}
|
package/libs/redis/redis.js
CHANGED
|
@@ -14,7 +14,9 @@ function buildRedisUrl(config) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Bun RedisClient
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
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.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
134
|
+
return this.strictCall("del", () => this.client.del(`${this.prefix}${key}`));
|
|
144
135
|
}
|
|
145
136
|
|
|
146
137
|
async delBatch(keys) {
|
|
147
|
-
return this.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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, "
|
|
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"], "
|
|
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) }));
|