befly 3.77.0 → 3.77.2
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 +2 -0
- package/apis/admin/_meta.json +3 -0
- package/apis/api/_meta.json +3 -0
- package/apis/auth/_meta.json +3 -0
- package/apis/dashboard/_meta.json +3 -0
- package/apis/dict/_meta.json +3 -0
- package/apis/dictType/_meta.json +3 -0
- package/apis/email/_meta.json +3 -0
- package/apis/loginLog/_meta.json +3 -0
- package/apis/menu/_meta.json +3 -0
- package/apis/operateLog/_meta.json +3 -0
- package/apis/role/_meta.json +3 -0
- package/apis/source/_meta.json +3 -0
- package/apis/tongJi/_meta.json +3 -0
- package/apis/tongJi/dailyReport.js +1 -1
- package/apis/tongJi/errorReport.js +1 -1
- package/apis/upload/_meta.json +3 -0
- package/checks/table.js +1 -1
- package/configs/beflyConfig.json +4 -1
- package/index.js +3 -1
- package/libs/logger/logger.js +81 -20
- package/libs/logger/sanitize.js +40 -15
- package/libs/validator/compiler.js +2 -2
- package/libs/validator/parser.js +1 -1
- package/package.json +1 -1
- package/schemas/api.json +6 -0
- package/schemas/config.json +18 -0
- package/sync/api.js +4 -1
- package/tables/api.json +9 -0
- package/utils/prettyError.js +102 -0
- package/utils/scanFiles.js +57 -1
- package/utils/scanSources.js +46 -4
package/Befly.js
CHANGED
|
@@ -13,6 +13,7 @@ import { syncMenu } from "./sync/menu.js";
|
|
|
13
13
|
import { calcPerfTime } from "./utils/calcPerfTime.js";
|
|
14
14
|
import { createError } from "./utils/error.js";
|
|
15
15
|
import { isPrimaryProcess } from "./utils/is.js";
|
|
16
|
+
import { printErrorSummary } from "./utils/prettyError.js";
|
|
16
17
|
import { waitFor } from "./utils/util.js";
|
|
17
18
|
|
|
18
19
|
const SYNC_READY_KEY = "befly:syncReady";
|
|
@@ -162,6 +163,7 @@ export class Befly {
|
|
|
162
163
|
process.once("SIGTERM", this.signalHandlers.SIGTERM);
|
|
163
164
|
return this.server;
|
|
164
165
|
} catch (error) {
|
|
166
|
+
printErrorSummary(error, { title: "项目启动失败" });
|
|
165
167
|
Logger.error("项目启动失败", error);
|
|
166
168
|
try {
|
|
167
169
|
await this.stop();
|
|
@@ -52,7 +52,7 @@ export default {
|
|
|
52
52
|
const productCode = body.productCode || "";
|
|
53
53
|
|
|
54
54
|
if (!(await isRegisteredProductCode(befly, productCode))) {
|
|
55
|
-
return befly.tool.No("
|
|
55
|
+
return befly.tool.No("产品代号未注册");
|
|
56
56
|
}
|
|
57
57
|
const rawMessage = body.message || "";
|
|
58
58
|
const extractedMessage = extractErrorMessage(rawMessage);
|
package/checks/table.js
CHANGED
|
@@ -26,7 +26,7 @@ function checkFieldDefinition(issues, tableName, fieldName, field) {
|
|
|
26
26
|
if (typeof field.name !== "string" || !field.name || field.name !== field.name.trim() || field.name.includes("\n") || field.name.length > 20) addIssue(issues, [...path, "name"], "必须是 1..20 字符的简短名称,不允许首尾空白或换行");
|
|
27
27
|
if (field.detail !== undefined && (typeof field.detail !== "string" || !field.detail || field.detail !== field.detail.trim())) addIssue(issues, [...path, "detail"], "必须是非空无首尾空白字符串");
|
|
28
28
|
if (!fieldParamTypes[field.fieldType]) addIssue(issues, [...path, "fieldType"], "必须是 integer、number、varchar、text");
|
|
29
|
-
if (typeof field.fieldNullable !== "boolean") addIssue(issues, [...path, "fieldNullable"], "
|
|
29
|
+
if (typeof field.fieldNullable !== "boolean") addIssue(issues, [...path, "fieldNullable"], "必须是布尔值");
|
|
30
30
|
|
|
31
31
|
let parseField;
|
|
32
32
|
try {
|
package/configs/beflyConfig.json
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"excludeApisLog": ["/api/core/tongJi/*Report"],
|
|
20
20
|
"logger": {
|
|
21
21
|
"debug": true,
|
|
22
|
-
"excludeFields": ["password", "token", "secret"]
|
|
22
|
+
"excludeFields": ["password", "token", "secret"],
|
|
23
|
+
"truncateStringLength": 512,
|
|
24
|
+
"truncateArrayLength": 20,
|
|
25
|
+
"truncatePreviewLength": 200
|
|
23
26
|
},
|
|
24
27
|
"mysql": {
|
|
25
28
|
"hostname": "127.0.0.1",
|
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
|
|
|
@@ -38,7 +39,7 @@ export async function createBefly(config = {}, menus = []) {
|
|
|
38
39
|
const mergedConfig = deepMerge(beflyConfig, config);
|
|
39
40
|
const mergedMenus = deepMerge(prefixMenuPaths(beflyMenus, "core"), menus);
|
|
40
41
|
|
|
41
|
-
Logger.configure({ runtimeEnv: getRunMode(), ...mergedConfig.logger });
|
|
42
|
+
await Logger.configure({ runtimeEnv: getRunMode(), ...mergedConfig.logger });
|
|
42
43
|
Logger.info(`启动 合并配置 耗时 ${calcPerfTime(mergeStartTime)}`);
|
|
43
44
|
|
|
44
45
|
let stageStartTime = Bun.nanoseconds();
|
|
@@ -80,6 +81,7 @@ export async function createBefly(config = {}, menus = []) {
|
|
|
80
81
|
corns: corns
|
|
81
82
|
});
|
|
82
83
|
} catch (error) {
|
|
84
|
+
printErrorSummary(error, { title: "启动失败" });
|
|
83
85
|
Logger.error("启动失败", error);
|
|
84
86
|
await Logger.shutdown();
|
|
85
87
|
throw error;
|
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,30 +111,62 @@ 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
|
|
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;
|
|
106
134
|
// 链尾 catch 隔离单次写失败:保持 pending 永远 resolved,
|
|
107
135
|
// 避免未处理 rejection 与后续日志全部静默丢失
|
|
108
136
|
this.pending = this.pending
|
|
109
137
|
.then(async () => {
|
|
110
|
-
await this.
|
|
111
|
-
if (!this.stream || this.disabled) return;
|
|
112
|
-
this.size += bytes;
|
|
113
|
-
await new Promise((resolveWrite) => {
|
|
114
|
-
this.stream.write(line, resolveWrite);
|
|
115
|
-
});
|
|
138
|
+
await this.drain();
|
|
116
139
|
})
|
|
117
140
|
.catch((error) => {
|
|
118
141
|
writeStderr(`写入失败 (${this.prefix}): ${error?.message || error}`);
|
|
119
142
|
});
|
|
120
143
|
}
|
|
121
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);
|
|
162
|
+
if (!this.stream || this.disabled) return;
|
|
163
|
+
this.size += batch.bytes;
|
|
164
|
+
await new Promise((resolveWrite) => {
|
|
165
|
+
this.stream.write(batch.buffer, resolveWrite);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
122
170
|
async flush() {
|
|
123
171
|
await this.pending;
|
|
124
172
|
}
|
|
@@ -129,6 +177,13 @@ class FileSink {
|
|
|
129
177
|
}
|
|
130
178
|
}
|
|
131
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
|
+
|
|
132
187
|
function resetConfig(options = {}) {
|
|
133
188
|
const runtimeEnv = options.runtimeEnv === "development" ? "development" : "production";
|
|
134
189
|
const dir = options.dir ? resolve(options.dir) : resolve(process.cwd(), "logs");
|
|
@@ -146,7 +201,10 @@ function resetConfig(options = {}) {
|
|
|
146
201
|
sanitizeOptions = {
|
|
147
202
|
sanitizeDepth: normalizeInteger(options.sanitizeDepth, 5, 1, 10),
|
|
148
203
|
sanitizeNodes: normalizeInteger(options.sanitizeNodes, 5000, 50, 20000),
|
|
149
|
-
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),
|
|
150
208
|
sensitiveKeyMatcher: buildSensitiveKeyMatcher(builtinSensitiveKeys, options.excludeFields)
|
|
151
209
|
};
|
|
152
210
|
}
|
|
@@ -191,7 +249,7 @@ function toRecord(input) {
|
|
|
191
249
|
try {
|
|
192
250
|
return { msg: String(input) };
|
|
193
251
|
} catch {
|
|
194
|
-
return { msg: "[
|
|
252
|
+
return { msg: "[无法序列化的日志记录]" };
|
|
195
253
|
}
|
|
196
254
|
}
|
|
197
255
|
|
|
@@ -208,7 +266,7 @@ function buildLine(level, record) {
|
|
|
208
266
|
try {
|
|
209
267
|
return `${JSON.stringify(output)}\n`;
|
|
210
268
|
} catch {
|
|
211
|
-
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`;
|
|
212
270
|
}
|
|
213
271
|
}
|
|
214
272
|
|
|
@@ -237,13 +295,14 @@ export const Logger = {
|
|
|
237
295
|
write("error", isPlainObject(message) ? message : { msg: message, err: error, data: data });
|
|
238
296
|
},
|
|
239
297
|
debug: createMethod("debug"),
|
|
240
|
-
configure: function (options = {}) {
|
|
298
|
+
configure: async function (options = {}) {
|
|
241
299
|
const sinks = [appSink, errorSink].filter(Boolean);
|
|
242
300
|
appSink = null;
|
|
243
301
|
errorSink = null;
|
|
244
|
-
|
|
302
|
+
// 配置即时生效(同步重置),旧 sink 关闭异步等待,避免新旧并发期间的配置错位
|
|
245
303
|
resetConfig(options);
|
|
246
304
|
prepareDirectory(config.clearDevelopmentLog);
|
|
305
|
+
await Promise.allSettled(sinks.map((sink) => sink.shutdown()));
|
|
247
306
|
},
|
|
248
307
|
setMock: function (mock) {
|
|
249
308
|
mockInstance = mock;
|
|
@@ -262,3 +321,5 @@ export const Logger = {
|
|
|
262
321
|
}
|
|
263
322
|
}
|
|
264
323
|
};
|
|
324
|
+
|
|
325
|
+
export { FileSink };
|
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) {
|
|
@@ -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) {
|
|
@@ -212,7 +212,7 @@ export function compileNode(schema, path, parents) {
|
|
|
212
212
|
if (schema.paramType === "object") {
|
|
213
213
|
assertItemBounds(schema, path);
|
|
214
214
|
if (schema.fields !== undefined && !isPlainObject(schema.fields)) schemaError([...path, "fields"], "必须是对象");
|
|
215
|
-
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 对象");
|
|
216
216
|
node.minItem = schema.minItem;
|
|
217
217
|
node.maxItem = schema.maxItem;
|
|
218
218
|
node.fields = Object.entries(schema.fields || {}).map(([key, child]) => ({ key: key, node: compileNode(child, [...path, "fields", key], parents) }));
|
package/libs/validator/parser.js
CHANGED
|
@@ -5,7 +5,7 @@ function addIssue(issues, path, code, message) {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
function typeMessage(kind) {
|
|
8
|
-
if (kind === "boolean") return "
|
|
8
|
+
if (kind === "boolean") return "必须是布尔值";
|
|
9
9
|
if (kind === "integer") return "必须是安全整数";
|
|
10
10
|
if (kind === "number") return "必须是有限数字";
|
|
11
11
|
if (kind === "string") return "必须是字符串";
|
package/package.json
CHANGED
package/schemas/api.json
CHANGED
package/schemas/config.json
CHANGED
|
@@ -78,6 +78,24 @@
|
|
|
78
78
|
"items": {
|
|
79
79
|
"paramType": "string"
|
|
80
80
|
}
|
|
81
|
+
},
|
|
82
|
+
"truncateStringLength": {
|
|
83
|
+
"paramType": "integer",
|
|
84
|
+
"minValue": 32,
|
|
85
|
+
"maxValue": 65536,
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"truncateArrayLength": {
|
|
89
|
+
"paramType": "integer",
|
|
90
|
+
"minValue": 1,
|
|
91
|
+
"maxValue": 1000,
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"truncatePreviewLength": {
|
|
95
|
+
"paramType": "integer",
|
|
96
|
+
"minValue": 32,
|
|
97
|
+
"maxValue": 65536,
|
|
98
|
+
"optional": true
|
|
81
99
|
}
|
|
82
100
|
}
|
|
83
101
|
},
|
package/sync/api.js
CHANGED
|
@@ -26,7 +26,7 @@ const getApiParentPath = (apiPath) => {
|
|
|
26
26
|
export async function syncApi(ctx, apis) {
|
|
27
27
|
const allDbApis = await ctx.mysql.getAll({
|
|
28
28
|
table: BEFLY_API_TABLE,
|
|
29
|
-
fields: ["id", "path", "parentPath", "name", "method", "auth", "category", "state"],
|
|
29
|
+
fields: ["id", "path", "parentPath", "parentTitle", "name", "method", "auth", "category", "state"],
|
|
30
30
|
where: { state$gte: 0 }
|
|
31
31
|
});
|
|
32
32
|
|
|
@@ -37,6 +37,7 @@ export async function syncApi(ctx, apis) {
|
|
|
37
37
|
path: api.apiPath,
|
|
38
38
|
method: api.method,
|
|
39
39
|
parentPath: getApiParentPath(api.apiPath),
|
|
40
|
+
parentTitle: api.parentTitle || "",
|
|
40
41
|
auth: serializeAuth(api.auth),
|
|
41
42
|
category: api.category || "other"
|
|
42
43
|
});
|
|
@@ -50,6 +51,7 @@ export async function syncApi(ctx, apis) {
|
|
|
50
51
|
path: def.path,
|
|
51
52
|
method: def.method,
|
|
52
53
|
parentPath: def.parentPath,
|
|
54
|
+
parentTitle: def.parentTitle,
|
|
53
55
|
auth: def.auth,
|
|
54
56
|
category: def.category
|
|
55
57
|
}),
|
|
@@ -58,6 +60,7 @@ export async function syncApi(ctx, apis) {
|
|
|
58
60
|
path: def.path,
|
|
59
61
|
method: def.method,
|
|
60
62
|
parentPath: def.parentPath,
|
|
63
|
+
parentTitle: def.parentTitle,
|
|
61
64
|
auth: def.auth,
|
|
62
65
|
category: def.category
|
|
63
66
|
})
|
package/tables/api.json
CHANGED
|
@@ -52,5 +52,14 @@
|
|
|
52
52
|
"paramType": "string",
|
|
53
53
|
"fieldType": "varchar",
|
|
54
54
|
"fieldDefault": ""
|
|
55
|
+
},
|
|
56
|
+
"parentTitle": {
|
|
57
|
+
"name": "目录标题",
|
|
58
|
+
"detail": "目录标题路径:每级有 _meta.js 标题用标题,没有用目录名",
|
|
59
|
+
"minValue": 0,
|
|
60
|
+
"maxValue": 100,
|
|
61
|
+
"paramType": "string",
|
|
62
|
+
"fieldType": "varchar",
|
|
63
|
+
"fieldDefault": ""
|
|
55
64
|
}
|
|
56
65
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
|
|
3
|
+
// 摘要中每组错误最多列出的文件数,其余以计数收尾(完整清单在日志里)
|
|
4
|
+
const MAX_FILES_PER_GROUP = 3;
|
|
5
|
+
|
|
6
|
+
// 高频错误的修复提示(按内容模式匹配,只保留最有价值的少数几条)
|
|
7
|
+
const HINT_RULES = [
|
|
8
|
+
{
|
|
9
|
+
match: (text) => text.includes("不允许出现"),
|
|
10
|
+
hint: "疑似新增字段未在对应 schema 中声明"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
match: (text) => text.includes("必须是") || text.includes("必填"),
|
|
14
|
+
hint: "检查对应字段的类型与取值是否符合 schema 声明"
|
|
15
|
+
}
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function shortPath(file) {
|
|
19
|
+
const text = String(file || "");
|
|
20
|
+
if (!text) return "(未知来源)";
|
|
21
|
+
const related = relative(process.cwd(), text);
|
|
22
|
+
return (related.length < text.length ? related : text).replaceAll("\\", "/");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 兼容字符串化的错误项(经 sanitize 深度降级后会变成 JSON 字符串)
|
|
26
|
+
function toErrorItems(errors) {
|
|
27
|
+
if (!Array.isArray(errors)) return [];
|
|
28
|
+
return errors.map((item) => {
|
|
29
|
+
if (item && typeof item === "object") return item;
|
|
30
|
+
if (typeof item === "string" && item.startsWith("{")) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(item);
|
|
33
|
+
} catch {
|
|
34
|
+
return { expected: item };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { expected: String(item) };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectHints(texts) {
|
|
42
|
+
const hints = [];
|
|
43
|
+
for (const rule of HINT_RULES) {
|
|
44
|
+
if (hints.length >= 2) break;
|
|
45
|
+
if (texts.some((text) => rule.match(text)) && !hints.includes(rule.hint)) {
|
|
46
|
+
hints.push(rule.hint);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return hints;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 将结构化错误(含 checkAll 的 errors 分组)格式化为人类可读的多行摘要。
|
|
54
|
+
* 用于启动失败等关键错误的终端输出;日志仍记录完整结构化信息。
|
|
55
|
+
*/
|
|
56
|
+
export function formatErrorSummary(error, options = {}) {
|
|
57
|
+
const title = options.title || "启动失败";
|
|
58
|
+
const lines = [`✗ ${title}:${error?.message || error}`];
|
|
59
|
+
|
|
60
|
+
const groups = Array.isArray(error?.errors) ? error.errors : [];
|
|
61
|
+
const allTexts = [];
|
|
62
|
+
for (const group of groups) {
|
|
63
|
+
const items = toErrorItems(group?.errors ?? group);
|
|
64
|
+
if (items.length === 0) continue;
|
|
65
|
+
|
|
66
|
+
// 按原因聚合:同因错误合并,文件清单截断展示
|
|
67
|
+
const byReason = new Map();
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const reason = item.expected || item.message || "(无原因)";
|
|
70
|
+
if (!byReason.has(reason)) byReason.set(reason, []);
|
|
71
|
+
byReason.get(reason).push(shortPath(item.file));
|
|
72
|
+
}
|
|
73
|
+
allTexts.push(...byReason.keys());
|
|
74
|
+
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push(`[${group?.check || "错误"}] 共 ${items.length} 处:`);
|
|
77
|
+
for (const [reason, files] of byReason) {
|
|
78
|
+
lines.push(` ${reason}`);
|
|
79
|
+
const shown = files.slice(0, MAX_FILES_PER_GROUP);
|
|
80
|
+
for (const file of shown) {
|
|
81
|
+
lines.push(` ${file}`);
|
|
82
|
+
}
|
|
83
|
+
if (files.length > shown.length) {
|
|
84
|
+
lines.push(` … 其余 ${files.length - shown.length} 个`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const hints = collectHints(allTexts);
|
|
90
|
+
if (hints.length > 0) {
|
|
91
|
+
lines.push("");
|
|
92
|
+
for (const hint of hints) {
|
|
93
|
+
lines.push(`提示:${hint}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function printErrorSummary(error, options = {}) {
|
|
101
|
+
Bun.stderr.write(`${formatErrorSummary(error, options)}\n`);
|
|
102
|
+
}
|
package/utils/scanFiles.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join, normalize, parse, relative } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { createError } from "./error.js";
|
|
@@ -12,6 +12,42 @@ const selectFields = {
|
|
|
12
12
|
state: { name: "状态", paramType: "integer" }
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* 扫描接口目录下的 _meta.json 目录元数据(约定:_ 前缀文件不注册路由)。
|
|
17
|
+
* 返回 Map<目录 apiPath, 中文标题>,键与接口 apiPath 的目录部分对齐,供 scanSources 挂载 parentTitle。
|
|
18
|
+
*/
|
|
19
|
+
export async function scanApiMetaTitles(dir, source) {
|
|
20
|
+
const titles = new Map();
|
|
21
|
+
if (!existsSync(dir)) return titles;
|
|
22
|
+
|
|
23
|
+
const glob = new Bun.Glob("**/_meta.json");
|
|
24
|
+
const files = await Array.fromAsync(
|
|
25
|
+
glob.scan({
|
|
26
|
+
cwd: dir,
|
|
27
|
+
onlyFiles: true,
|
|
28
|
+
absolute: true,
|
|
29
|
+
followSymlinks: true
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const filePath = normalize(file);
|
|
35
|
+
const meta = await importDefault(filePath, {});
|
|
36
|
+
const record = isPlainObject(meta) ? meta : {};
|
|
37
|
+
const title = record.title;
|
|
38
|
+
if (typeof title !== "string" || title.trim().length === 0 || title.trim().length > 50) {
|
|
39
|
+
throw createError(`目录元数据无效:${filePath} 的 title 必须是 1-50 个字符`, { code: "policy", subsystem: "scan", operation: "scanApiMetaTitles" });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parsedRelativePath = parse(relative(dir, filePath));
|
|
43
|
+
const relativeDir = parsedRelativePath.dir.replaceAll("\\", "/");
|
|
44
|
+
const dirApiPath = source === "core" ? `/api/core/${relativeDir}` : `/api/${relativeDir}`;
|
|
45
|
+
titles.set(dirApiPath, title.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return titles;
|
|
49
|
+
}
|
|
50
|
+
|
|
15
51
|
/**
|
|
16
52
|
* 扫描指定目录下的文件
|
|
17
53
|
* @param dir 目录路径
|
|
@@ -83,3 +119,23 @@ export async function scanFiles(dir, source, type, pattern) {
|
|
|
83
119
|
throw createError(`扫描失败: source=${source} type=${type} dir=${dir} pattern=${pattern}`, { cause: error, code: "runtime" });
|
|
84
120
|
}
|
|
85
121
|
}
|
|
122
|
+
|
|
123
|
+
// 枚举接口目录下全部子目录(任意深度,跳过 _ 前缀段),返回目录 apiPath 集合,
|
|
124
|
+
// 供启动校验"每个目录必须有 _meta.js"使用
|
|
125
|
+
export function scanApiDirPaths(dir, source) {
|
|
126
|
+
const result = new Set();
|
|
127
|
+
if (!existsSync(dir)) return result;
|
|
128
|
+
const prefix = source === "core" ? "/api/core/" : "/api/";
|
|
129
|
+
|
|
130
|
+
function collect(current, relativeDir) {
|
|
131
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
132
|
+
if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
|
|
133
|
+
const childRelative = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
134
|
+
result.add(`${prefix}${childRelative}`);
|
|
135
|
+
collect(join(current, entry.name), childRelative);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
collect(dir, "");
|
|
140
|
+
return result;
|
|
141
|
+
}
|
package/utils/scanSources.js
CHANGED
|
@@ -16,7 +16,24 @@ import {
|
|
|
16
16
|
appCornDir
|
|
17
17
|
} from "../paths.js";
|
|
18
18
|
import { createError } from "./error.js";
|
|
19
|
-
import { scanFiles } from "./scanFiles.js";
|
|
19
|
+
import { scanApiDirPaths, scanApiMetaTitles, scanFiles } from "./scanFiles.js";
|
|
20
|
+
|
|
21
|
+
// 逐级拼接目录显示标题:每级目录有 _meta.js 标题用标题,没有用目录名,以 / 连接
|
|
22
|
+
// 例:/api/admin/product/insert + 标题映射 -> "后台管理/product"
|
|
23
|
+
export function composeTitlePath(apiPath, metaTitles) {
|
|
24
|
+
const segments = apiPath.split("/").filter(Boolean);
|
|
25
|
+
const start = segments[1] === "core" ? 2 : 1;
|
|
26
|
+
const dirs = segments.slice(start, -1);
|
|
27
|
+
if (dirs.length === 0) return "";
|
|
28
|
+
|
|
29
|
+
const parts = [];
|
|
30
|
+
let current = `/${segments.slice(0, start).join("/")}`;
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
current = `${current}/${dir}`;
|
|
33
|
+
parts.push(metaTitles.get(current) || dir);
|
|
34
|
+
}
|
|
35
|
+
return parts.join("/");
|
|
36
|
+
}
|
|
20
37
|
|
|
21
38
|
function warnWhenAppSourcesMissing() {
|
|
22
39
|
// 全部 app 源码目录都不存在时显式告警,避免"项目源码静默消失、以纯 core 形态运行"的配置事故
|
|
@@ -119,17 +136,42 @@ export const scanSources = async ({ beflyMode = "auto" } = {}) => {
|
|
|
119
136
|
corns.push(item);
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
//
|
|
123
|
-
const allCoreApis
|
|
139
|
+
// 处理接口(读取目录 _meta.js 标题挂载 parentTitle)
|
|
140
|
+
const [allCoreApis, coreMetaTitles, coreDirPaths, allAppApis, appMetaTitles, appDirPaths] = await Promise.all([
|
|
141
|
+
scanFiles(coreApiDir, "core", "api", "**/*.js"),
|
|
142
|
+
scanApiMetaTitles(coreApiDir, "core"),
|
|
143
|
+
scanApiDirPaths(coreApiDir, "core"),
|
|
144
|
+
scanFiles(appApiDir, "app", "api", "**/*.js"),
|
|
145
|
+
scanApiMetaTitles(appApiDir, "app"),
|
|
146
|
+
scanApiDirPaths(appApiDir, "app")
|
|
147
|
+
]);
|
|
148
|
+
|
|
124
149
|
for (const item of allCoreApis) {
|
|
150
|
+
item.parentTitle = composeTitlePath(item.apiPath, coreMetaTitles);
|
|
125
151
|
apis.push(item);
|
|
126
152
|
}
|
|
127
|
-
const allAppApis = await scanFiles(appApiDir, "app", "api", "**/*.js");
|
|
128
153
|
for (const item of allAppApis) {
|
|
129
154
|
assertAppApiNamespace(item);
|
|
155
|
+
item.parentTitle = composeTitlePath(item.apiPath, appMetaTitles);
|
|
130
156
|
apis.push(item);
|
|
131
157
|
}
|
|
132
158
|
|
|
159
|
+
// 每个接口目录(含空目录,_ 前缀隔离目录除外)都必须有 _meta.js 中文标题
|
|
160
|
+
const missingTitleDirs = new Set();
|
|
161
|
+
for (const dirPath of coreDirPaths) {
|
|
162
|
+
if (!coreMetaTitles.has(dirPath)) missingTitleDirs.add(dirPath);
|
|
163
|
+
}
|
|
164
|
+
for (const dirPath of appDirPaths) {
|
|
165
|
+
if (!appMetaTitles.has(dirPath)) missingTitleDirs.add(dirPath);
|
|
166
|
+
}
|
|
167
|
+
if (missingTitleDirs.size > 0) {
|
|
168
|
+
throw createError(`以下接口目录缺少 _meta.json 中文标题:${Array.from(missingTitleDirs).toSorted().join("、")}`, {
|
|
169
|
+
code: "policy",
|
|
170
|
+
subsystem: "scan",
|
|
171
|
+
operation: "scanSources"
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
133
175
|
assertUnique(plugins, "fileName", "Plugin 文件名");
|
|
134
176
|
assertUnique(hooks, "fileName", "Hook 文件名");
|
|
135
177
|
assertUnique(corns, "fileName", "Cron 文件名");
|