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 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();
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "系统管理"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "接口管理"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "认证登录"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "仪表盘"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "字典项"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "字典类型"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "邮件"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "登录日志"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "菜单"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "操作日志"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "角色"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "资源"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "数据统计"
3
+ }
@@ -30,7 +30,7 @@ export default {
30
30
  const productCode = ctx.body?.productCode || "";
31
31
 
32
32
  if (!(await isRegisteredProductCode(befly, productCode))) {
33
- return befly.tool.No("productCode 未注册");
33
+ return befly.tool.No("产品代号未注册");
34
34
  }
35
35
 
36
36
  let memberMeta;
@@ -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("productCode 未注册");
55
+ return befly.tool.No("产品代号未注册");
56
56
  }
57
57
  const rawMessage = body.message || "";
58
58
  const extractedMessage = extractErrorMessage(rawMessage);
@@ -0,0 +1,3 @@
1
+ {
2
+ "title": "上传"
3
+ }
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"], "必须是 boolean");
29
+ if (typeof field.fieldNullable !== "boolean") addIssue(issues, [...path, "fieldNullable"], "必须是布尔值");
30
30
 
31
31
  let parseField;
32
32
  try {
@@ -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;
@@ -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
- return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
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
- const date = formatTime(Date.now(), true);
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 = 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 bytes = textEncoder.encode(line).byteLength;
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.prepare(bytes);
111
- if (!this.stream || this.disabled) return;
112
- this.size += bytes;
113
- await new Promise((resolveWrite) => {
114
- this.stream.write(line, resolveWrite);
115
- });
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, 500, 10, 5000),
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: "[Unserializable]" };
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: "[Unserializable log record]" })}\n`;
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
- for (const sink of sinks) sink.shutdown().catch((error) => writeStderr(`关闭失败: ${error.message || error}`));
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 };
@@ -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) {
@@ -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) {
@@ -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"], "必须是 boolean 或 Schema");
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) }));
@@ -5,7 +5,7 @@ function addIssue(issues, path, code, message) {
5
5
  }
6
6
 
7
7
  function typeMessage(kind) {
8
- if (kind === "boolean") return "必须是 boolean";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "befly",
3
- "version": "3.77.0",
3
+ "version": "3.77.2",
4
4
  "gitHead": "49c39d36695036e85fc64083cc43c1652fff96cb",
5
5
  "private": false,
6
6
  "description": "Befly - 为 Bun 专属打造的 JavaScript API 接口框架核心引擎",
package/schemas/api.json CHANGED
@@ -19,6 +19,12 @@
19
19
  "paramType": "string",
20
20
  "minValue": 1
21
21
  },
22
+ "parentTitle": {
23
+ "paramType": "string",
24
+ "minValue": 0,
25
+ "maxValue": 500,
26
+ "optional": true
27
+ },
22
28
  "apiPath": {
23
29
  "paramType": "string",
24
30
  "minValue": 1,
@@ -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
+ }
@@ -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
+ }
@@ -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 = await scanFiles(coreApiDir, "core", "api", "**/*.js");
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 文件名");