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/hooks/auth.js
CHANGED
|
@@ -29,8 +29,14 @@ export default {
|
|
|
29
29
|
ctx.roleCode = sessionData.roleCode;
|
|
30
30
|
ctx.roleType = sessionData.roleType;
|
|
31
31
|
|
|
32
|
+
// 距上次续期不足半衰期时不续,避免每个请求两次 Redis 往返
|
|
33
|
+
const renewedAt = Number(sessionData.renewedAt || sessionData.loginAt || 0);
|
|
34
|
+
if (Date.now() - renewedAt < (ttlSeconds * 1000) / 2) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
try {
|
|
33
|
-
await befly.redis.expire(sessionKey, ttlSeconds);
|
|
39
|
+
await Promise.all([befly.redis.expire(sessionKey, ttlSeconds), befly.redis.setObject(sessionKey, { ...sessionData, renewedAt: Date.now() }, ttlSeconds)]);
|
|
34
40
|
} catch (error) {
|
|
35
41
|
Logger.warn("刷新会话有效期失败", { error: error, sessionKey: sessionKey });
|
|
36
42
|
}
|
package/hooks/permission.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { HOOK_ORDER } from "../configs/constConfig.js";
|
|
2
|
-
import { isValidPositiveInt } from "../utils/is.js";
|
|
2
|
+
import { isSuperAdminContext, isValidPositiveInt } from "../utils/is.js";
|
|
3
3
|
// 相对导入
|
|
4
4
|
import { ErrorResponse } from "../utils/response.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* 权限检查钩子
|
|
8
8
|
* - 接口无需权限(auth=false):直接通过
|
|
9
|
+
* - 超级管理员专属(auth="dev"):仅管理员表 dev/admin 角色可用,不参与角色授权
|
|
9
10
|
* - auth 为角色类型白名单(string[],如 ["admin"] / ["user"]):按 ctx.roleType 校验。
|
|
10
11
|
* 类型与角色解耦:管理员表(beflyAdmin)roleType 固定 "admin",用户表固定 "user",
|
|
11
12
|
* 任意角色 code 的管理员共享 "admin" 类型门槛,细粒度授权由下方集合检查(roleCode)负责
|
|
@@ -33,6 +34,26 @@ export default {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// 3.4 超级管理员专属接口(auth="dev"):仅管理员表 dev/admin 角色可用,
|
|
38
|
+
// 为天生权限,不查角色权限集合,也不允许分配给任何角色
|
|
39
|
+
if (ctx.apiAuth === "dev") {
|
|
40
|
+
if (isSuperAdminContext(ctx)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
ctx.response = ErrorResponse(
|
|
44
|
+
ctx,
|
|
45
|
+
`无权访问 ${ctx.apiName} 接口`,
|
|
46
|
+
1,
|
|
47
|
+
null,
|
|
48
|
+
{
|
|
49
|
+
apiName: ctx.apiName,
|
|
50
|
+
apiPath: ctx.apiPath
|
|
51
|
+
},
|
|
52
|
+
"permission"
|
|
53
|
+
);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
// 3.5 apiAuth 为角色类型白名单时,仅做 ctx.roleType 校验
|
|
37
58
|
if (Array.isArray(ctx.apiAuth) && ctx.apiAuth.includes(ctx.roleType) === false) {
|
|
38
59
|
ctx.response = ErrorResponse(
|
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
|
@@ -12,6 +12,7 @@ import beflyMenus from "./configs/beflyMenus.json";
|
|
|
12
12
|
import { calcPerfTime } from "./utils/calcPerfTime.js";
|
|
13
13
|
import { deepMerge } from "./utils/deepMerge.js";
|
|
14
14
|
import { createError } from "./utils/error.js";
|
|
15
|
+
import { printErrorSummary } from "./utils/prettyError.js";
|
|
15
16
|
import { scanSources } from "./utils/scanSources.js";
|
|
16
17
|
import { getRunMode } from "./utils/util.js";
|
|
17
18
|
|
|
@@ -33,49 +34,56 @@ function prefixMenuPaths(menus, prefix) {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export async function createBefly(config = {}, menus = []) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
try {
|
|
38
|
+
const mergeStartTime = Bun.nanoseconds();
|
|
39
|
+
const mergedConfig = deepMerge(beflyConfig, config);
|
|
40
|
+
const mergedMenus = deepMerge(prefixMenuPaths(beflyMenus, "core"), menus);
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
await Logger.configure({ runtimeEnv: getRunMode(), ...mergedConfig.logger });
|
|
43
|
+
Logger.info(`启动 合并配置 耗时 ${calcPerfTime(mergeStartTime)}`);
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
let stageStartTime = Bun.nanoseconds();
|
|
46
|
+
const configErrors = await checkConfig(mergedConfig);
|
|
47
|
+
Logger.info(`启动 检查配置 耗时 ${calcPerfTime(stageStartTime)}`);
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
stageStartTime = Bun.nanoseconds();
|
|
50
|
+
const { apis, tables, plugins, hooks, corns } = await scanSources({ beflyMode: mergedConfig.mysql.beflyMode });
|
|
51
|
+
Logger.info(`启动 扫描源码 耗时 ${calcPerfTime(stageStartTime)}`);
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
const checks = [
|
|
54
|
+
["配置", configErrors],
|
|
55
|
+
["接口", await checkApi(apis)],
|
|
56
|
+
["表结构", await checkTable(tables)],
|
|
57
|
+
["插件", await checkPlugin(plugins)],
|
|
58
|
+
["钩子", await checkHook(hooks)],
|
|
59
|
+
["定时器", checkCorn(corns)],
|
|
60
|
+
["菜单", await checkMenu(mergedMenus)]
|
|
61
|
+
];
|
|
62
|
+
const failedChecks = checks.filter(([, errors]) => errors.length > 0).map(([check, errors]) => ({ check: check, errors: errors }));
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
if (failedChecks.length > 0) {
|
|
65
|
+
throw createError("检查失败:存在配置/结构问题", {
|
|
66
|
+
code: "policy",
|
|
67
|
+
subsystem: "checks",
|
|
68
|
+
operation: "checkAll",
|
|
69
|
+
errors: failedChecks
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
mergedConfig.runMode = getRunMode();
|
|
74
|
+
return new Befly({
|
|
75
|
+
config: mergedConfig,
|
|
76
|
+
tables: tables,
|
|
77
|
+
menus: mergedMenus,
|
|
78
|
+
apis: apis,
|
|
79
|
+
hooks: hooks,
|
|
80
|
+
plugins: plugins,
|
|
81
|
+
corns: corns
|
|
68
82
|
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
printErrorSummary(error, { title: "启动失败" });
|
|
85
|
+
Logger.error("启动失败", error);
|
|
86
|
+
await Logger.shutdown();
|
|
87
|
+
throw error;
|
|
69
88
|
}
|
|
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
89
|
}
|
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
|
@@ -6,6 +6,10 @@ import { buildSensitiveKeyMatcher, isPlainObject, sanitizeLogRecord } from "./sa
|
|
|
6
6
|
const builtinSensitiveKeys = ["*password*", "pass", "pwd", "*token*", "access_token", "refresh_token", "accessToken", "refreshToken", "authorization", "cookie", "set-cookie", "*secret*", "apiKey", "api_key", "privateKey", "private_key"];
|
|
7
7
|
const textEncoder = new TextEncoder();
|
|
8
8
|
|
|
9
|
+
// 单条日志与单次写入批次的字节上限(正常不触发,仅极端兜底)
|
|
10
|
+
const MAX_LINE_LENGTH = 65536;
|
|
11
|
+
const MAX_WRITE_BATCH_BYTES = 65536;
|
|
12
|
+
|
|
9
13
|
let config;
|
|
10
14
|
let sanitizeOptions;
|
|
11
15
|
let mockInstance = null;
|
|
@@ -26,13 +30,20 @@ function formatTime(value, dateOnly = false) {
|
|
|
26
30
|
const hour = String(date.getHours()).padStart(2, "0");
|
|
27
31
|
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
28
32
|
const second = String(date.getSeconds()).padStart(2, "0");
|
|
29
|
-
|
|
33
|
+
const millisecond = String(date.getMilliseconds()).padStart(3, "0");
|
|
34
|
+
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function writeStderr(message) {
|
|
33
38
|
Bun.stderr.write(`[befly-logger] ${message}\n`);
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// 次日零点时间戳:日期滚动判断只做一次数值比较,避免每行格式化日期字符串
|
|
42
|
+
function computeNextDayMs() {
|
|
43
|
+
const now = new Date();
|
|
44
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
class FileSink {
|
|
37
48
|
constructor(prefix, sinkConfig) {
|
|
38
49
|
this.prefix = prefix;
|
|
@@ -43,6 +54,11 @@ class FileSink {
|
|
|
43
54
|
this.size = 0;
|
|
44
55
|
this.pending = Promise.resolve();
|
|
45
56
|
this.disabled = false;
|
|
57
|
+
this.queue = [];
|
|
58
|
+
this.flushScheduled = false;
|
|
59
|
+
this.droppedCount = 0;
|
|
60
|
+
this.maxQueueLines = normalizeInteger(sinkConfig.maxQueueLines, 10000, 1, 100000);
|
|
61
|
+
this.nextDayMs = 0;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
getPath(date, index) {
|
|
@@ -59,6 +75,7 @@ class FileSink {
|
|
|
59
75
|
this.disabled = true;
|
|
60
76
|
writeStderr(`写入失败 (${this.prefix}): ${error.message || error}`);
|
|
61
77
|
});
|
|
78
|
+
this.nextDayMs = computeNextDayMs();
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
close(resetPosition = true) {
|
|
@@ -76,17 +93,16 @@ class FileSink {
|
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
async prepare(bytes) {
|
|
79
|
-
|
|
80
|
-
if (this.stream && this.date !== date) await this.close();
|
|
96
|
+
if (this.stream && Date.now() >= this.nextDayMs) await this.close();
|
|
81
97
|
|
|
82
98
|
if (!this.stream) {
|
|
83
|
-
this.date =
|
|
99
|
+
this.date = formatTime(Date.now(), true);
|
|
84
100
|
this.index = 0;
|
|
85
|
-
let path = this.getPath(date, this.index);
|
|
101
|
+
let path = this.getPath(this.date, this.index);
|
|
86
102
|
this.size = Bun.file(path).size;
|
|
87
103
|
while (this.prefix !== "dev" && this.size > 0 && this.size + bytes > this.config.maxBytes) {
|
|
88
104
|
this.index += 1;
|
|
89
|
-
path = this.getPath(date, this.index);
|
|
105
|
+
path = this.getPath(this.date, this.index);
|
|
90
106
|
this.size = Bun.file(path).size;
|
|
91
107
|
}
|
|
92
108
|
this.open(path);
|
|
@@ -95,22 +111,60 @@ class FileSink {
|
|
|
95
111
|
if (this.prefix !== "dev" && this.size > 0 && this.size + bytes > this.config.maxBytes) {
|
|
96
112
|
await this.close(false);
|
|
97
113
|
this.index += 1;
|
|
98
|
-
this.size = Bun.file(this.getPath(date, this.index)).size;
|
|
99
|
-
this.open(this.getPath(date, this.index));
|
|
114
|
+
this.size = Bun.file(this.getPath(this.date, this.index)).size;
|
|
115
|
+
this.open(this.getPath(this.date, this.index));
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
118
|
|
|
119
|
+
// 写入入口:单次编码、超长截断、入队(有界)、按需调度冲刷
|
|
103
120
|
write(line) {
|
|
104
121
|
if (this.disabled) return;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
const text = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}[截断,原 ${line.length} 字符]\n` : line;
|
|
123
|
+
const buffer = textEncoder.encode(text);
|
|
124
|
+
this.queue.push({ buffer: buffer, bytes: buffer.byteLength });
|
|
125
|
+
|
|
126
|
+
// 日志风暴/磁盘卡顿时有界丢弃最旧行,恢复后补摘要标记
|
|
127
|
+
while (this.queue.length > this.maxQueueLines) {
|
|
128
|
+
this.queue.shift();
|
|
129
|
+
this.droppedCount += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.flushScheduled) return;
|
|
133
|
+
this.flushScheduled = true;
|
|
134
|
+
// 链尾 catch 隔离单次写失败:保持 pending 永远 resolved,
|
|
135
|
+
// 避免未处理 rejection 与后续日志全部静默丢失
|
|
136
|
+
this.pending = this.pending
|
|
137
|
+
.then(async () => {
|
|
138
|
+
await this.drain();
|
|
139
|
+
})
|
|
140
|
+
.catch((error) => {
|
|
141
|
+
writeStderr(`写入失败 (${this.prefix}): ${error?.message || error}`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 冲刷队列:积压行合并为批次(单次系统调用),串行保持顺序
|
|
146
|
+
async drain() {
|
|
147
|
+
this.flushScheduled = false;
|
|
148
|
+
|
|
149
|
+
if (this.droppedCount > 0) {
|
|
150
|
+
const marker = textEncoder.encode(`{"level":"warn","time":"${formatTime(Date.now())}","pid":${process.pid},"msg":"[日志拥塞,已丢弃 ${this.droppedCount} 行]"}\n`);
|
|
151
|
+
this.queue.unshift({ buffer: marker, bytes: marker.byteLength });
|
|
152
|
+
this.droppedCount = 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
while (this.queue.length > 0) {
|
|
156
|
+
let batch = this.queue.shift();
|
|
157
|
+
while (this.queue.length > 0 && batch.bytes + this.queue[0].bytes <= MAX_WRITE_BATCH_BYTES) {
|
|
158
|
+
batch = mergeBatch(batch, this.queue.shift());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await this.prepare(batch.bytes);
|
|
108
162
|
if (!this.stream || this.disabled) return;
|
|
109
|
-
this.size += bytes;
|
|
163
|
+
this.size += batch.bytes;
|
|
110
164
|
await new Promise((resolveWrite) => {
|
|
111
|
-
this.stream.write(
|
|
165
|
+
this.stream.write(batch.buffer, resolveWrite);
|
|
112
166
|
});
|
|
113
|
-
}
|
|
167
|
+
}
|
|
114
168
|
}
|
|
115
169
|
|
|
116
170
|
async flush() {
|
|
@@ -123,6 +177,13 @@ class FileSink {
|
|
|
123
177
|
}
|
|
124
178
|
}
|
|
125
179
|
|
|
180
|
+
function mergeBatch(left, right) {
|
|
181
|
+
const merged = new Uint8Array(left.bytes + right.bytes);
|
|
182
|
+
merged.set(left.buffer, 0);
|
|
183
|
+
merged.set(right.buffer, left.bytes);
|
|
184
|
+
return { buffer: merged, bytes: merged.byteLength };
|
|
185
|
+
}
|
|
186
|
+
|
|
126
187
|
function resetConfig(options = {}) {
|
|
127
188
|
const runtimeEnv = options.runtimeEnv === "development" ? "development" : "production";
|
|
128
189
|
const dir = options.dir ? resolve(options.dir) : resolve(process.cwd(), "logs");
|
|
@@ -140,7 +201,10 @@ function resetConfig(options = {}) {
|
|
|
140
201
|
sanitizeOptions = {
|
|
141
202
|
sanitizeDepth: normalizeInteger(options.sanitizeDepth, 5, 1, 10),
|
|
142
203
|
sanitizeNodes: normalizeInteger(options.sanitizeNodes, 5000, 50, 20000),
|
|
143
|
-
sanitizeObjectKeys: normalizeInteger(options.sanitizeObjectKeys,
|
|
204
|
+
sanitizeObjectKeys: normalizeInteger(options.sanitizeObjectKeys, 50, 5, 500),
|
|
205
|
+
truncateStringLength: normalizeInteger(options.truncateStringLength, 512, 32, 65536),
|
|
206
|
+
truncateArrayLength: normalizeInteger(options.truncateArrayLength, 20, 1, 1000),
|
|
207
|
+
truncatePreviewLength: normalizeInteger(options.truncatePreviewLength, 200, 32, 65536),
|
|
144
208
|
sensitiveKeyMatcher: buildSensitiveKeyMatcher(builtinSensitiveKeys, options.excludeFields)
|
|
145
209
|
};
|
|
146
210
|
}
|
|
@@ -154,16 +218,25 @@ function prepareDirectory(clearDevelopmentLog) {
|
|
|
154
218
|
}
|
|
155
219
|
|
|
156
220
|
function getSink(level) {
|
|
157
|
-
|
|
221
|
+
// 目录只在新建 sink 时确保一次,避免每条日志一次 mkdirSync 系统调用
|
|
158
222
|
if (config.runtimeEnv === "development") {
|
|
159
|
-
if (!appSink)
|
|
223
|
+
if (!appSink) {
|
|
224
|
+
prepareDirectory(false);
|
|
225
|
+
appSink = new FileSink("dev", config);
|
|
226
|
+
}
|
|
160
227
|
return appSink;
|
|
161
228
|
}
|
|
162
229
|
if (level === "error") {
|
|
163
|
-
if (!errorSink)
|
|
230
|
+
if (!errorSink) {
|
|
231
|
+
prepareDirectory(false);
|
|
232
|
+
errorSink = new FileSink("error", config);
|
|
233
|
+
}
|
|
164
234
|
return errorSink;
|
|
165
235
|
}
|
|
166
|
-
if (!appSink)
|
|
236
|
+
if (!appSink) {
|
|
237
|
+
prepareDirectory(false);
|
|
238
|
+
appSink = new FileSink("app", config);
|
|
239
|
+
}
|
|
167
240
|
return appSink;
|
|
168
241
|
}
|
|
169
242
|
|
|
@@ -176,7 +249,7 @@ function toRecord(input) {
|
|
|
176
249
|
try {
|
|
177
250
|
return { msg: String(input) };
|
|
178
251
|
} catch {
|
|
179
|
-
return { msg: "[
|
|
252
|
+
return { msg: "[无法序列化的日志记录]" };
|
|
180
253
|
}
|
|
181
254
|
}
|
|
182
255
|
|
|
@@ -193,7 +266,7 @@ function buildLine(level, record) {
|
|
|
193
266
|
try {
|
|
194
267
|
return `${JSON.stringify(output)}\n`;
|
|
195
268
|
} catch {
|
|
196
|
-
return `${JSON.stringify({ level: level, time: output.time, pid: process.pid, msg: "[
|
|
269
|
+
return `${JSON.stringify({ level: level, time: output.time, pid: process.pid, msg: "[无法序列化的日志记录]" })}\n`;
|
|
197
270
|
}
|
|
198
271
|
}
|
|
199
272
|
|
|
@@ -222,13 +295,14 @@ export const Logger = {
|
|
|
222
295
|
write("error", isPlainObject(message) ? message : { msg: message, err: error, data: data });
|
|
223
296
|
},
|
|
224
297
|
debug: createMethod("debug"),
|
|
225
|
-
configure: function (options = {}) {
|
|
298
|
+
configure: async function (options = {}) {
|
|
226
299
|
const sinks = [appSink, errorSink].filter(Boolean);
|
|
227
300
|
appSink = null;
|
|
228
301
|
errorSink = null;
|
|
229
|
-
|
|
302
|
+
// 配置即时生效(同步重置),旧 sink 关闭异步等待,避免新旧并发期间的配置错位
|
|
230
303
|
resetConfig(options);
|
|
231
304
|
prepareDirectory(config.clearDevelopmentLog);
|
|
305
|
+
await Promise.allSettled(sinks.map((sink) => sink.shutdown()));
|
|
232
306
|
},
|
|
233
307
|
setMock: function (mock) {
|
|
234
308
|
mockInstance = mock;
|
|
@@ -241,6 +315,11 @@ export const Logger = {
|
|
|
241
315
|
const sinks = [appSink, errorSink].filter(Boolean);
|
|
242
316
|
appSink = null;
|
|
243
317
|
errorSink = null;
|
|
244
|
-
await Promise.
|
|
318
|
+
const results = await Promise.allSettled(sinks.map((sink) => sink.shutdown()));
|
|
319
|
+
for (const result of results) {
|
|
320
|
+
if (result.status === "rejected") writeStderr(`关闭失败: ${result.reason?.message || result.reason}`);
|
|
321
|
+
}
|
|
245
322
|
}
|
|
246
323
|
};
|
|
324
|
+
|
|
325
|
+
export { FileSink };
|