befly 3.76.6 → 3.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/Befly.js +99 -31
  2. package/apis/admin/delete.js +1 -1
  3. package/apis/admin/detail.js +1 -0
  4. package/apis/admin/insert.js +1 -1
  5. package/apis/admin/update.js +1 -1
  6. package/apis/auth/login.js +3 -2
  7. package/apis/dashboard/systemResources.js +12 -1
  8. package/apis/dict/detail.js +1 -0
  9. package/apis/dictType/detail.js +1 -0
  10. package/apis/email/config.js +1 -1
  11. package/apis/role/apiSave.js +14 -1
  12. package/apis/role/detail.js +1 -0
  13. package/apis/role/menuSave.js +1 -1
  14. package/apis/tongJi/_tongJi.js +16 -0
  15. package/apis/tongJi/dailyReport.js +5 -1
  16. package/apis/tongJi/dailyStatsDistribution.js +5 -4
  17. package/apis/tongJi/errorReport.js +8 -2
  18. package/apis/tongJi/todayOnline.js +2 -5
  19. package/checks/api.js +2 -6
  20. package/checks/field.js +30 -6
  21. package/checks/menu.js +2 -1
  22. package/configs/beflyConfig.json +3 -2
  23. package/exports.js +1 -0
  24. package/hooks/auth.js +7 -1
  25. package/hooks/permission.js +22 -1
  26. package/hooks/rateLimit.js +36 -5
  27. package/hooks/validator.js +5 -16
  28. package/index.js +44 -38
  29. package/libs/cacheHelper.js +13 -18
  30. package/libs/logger/logger.js +30 -12
  31. package/libs/memCache.js +53 -0
  32. package/libs/mysql/dbHelper.js +39 -71
  33. package/libs/mysql/dbParse.js +5 -1
  34. package/libs/mysql/sql/sqlBuilder.js +9 -0
  35. package/libs/redis/redis.js +44 -57
  36. package/libs/smtpText.js +41 -22
  37. package/libs/validator/compiler.js +1 -0
  38. package/libs/validator/parser.js +10 -4
  39. package/libs/validator/util.js +1 -5
  40. package/package.json +1 -1
  41. package/paths.js +23 -10
  42. package/router/static.js +13 -6
  43. package/schemas/api.json +4 -0
  44. package/schemas/config.json +20 -0
  45. package/sync/api.js +15 -7
  46. package/sync/dev.js +27 -3
  47. package/sync/menu.js +3 -3
  48. package/sync/syncUtil.js +12 -6
  49. package/tables/api.json +12 -0
  50. package/tables/emailLog.json +6 -2
  51. package/tables/menu.json +3 -0
  52. package/utils/is.js +7 -0
  53. package/utils/scanFiles.js +49 -46
  54. package/utils/scanSources.js +17 -0
  55. package/utils/util.js +5 -1
@@ -14,7 +14,9 @@ function buildRedisUrl(config) {
14
14
  }
15
15
 
16
16
  /**
17
- * Bun RedisClient 高层操作封装。除 ping 外,操作失败记录日志并返回约定 fallback。
17
+ * Bun RedisClient 高层操作封装。错误策略分级:
18
+ * - 读操作失败记录日志并返回约定 fallback,权限等判断按无数据处理(fail-closed)。
19
+ * - 写操作与计数器失败记录日志后抛出,由调用方决定降级策略,避免故障被静默吞掉。
18
20
  */
19
21
  export class RedisHelper {
20
22
  constructor(options) {
@@ -31,16 +33,21 @@ export class RedisHelper {
31
33
  }
32
34
  }
33
35
 
36
+ async strictCall(label, operation) {
37
+ try {
38
+ return await operation();
39
+ } catch (error) {
40
+ Logger.error(`Redis ${label} 错误`, error);
41
+ throw error;
42
+ }
43
+ }
44
+
34
45
  async setObject(key, obj, ttl = null) {
35
- return this.safeCall(
36
- "setObject",
37
- async () => {
38
- const data = JSON.stringify(obj);
39
- const pkey = `${this.prefix}${key}`;
40
- return ttl ? this.client.setex(pkey, ttl, data) : this.client.set(pkey, data);
41
- },
42
- null
43
- );
46
+ return this.strictCall("setObject", () => {
47
+ const data = JSON.stringify(obj);
48
+ const pkey = `${this.prefix}${key}`;
49
+ return ttl ? this.client.setex(pkey, ttl, data) : this.client.set(pkey, data);
50
+ });
44
51
  }
45
52
 
46
53
  async getObject(key) {
@@ -55,20 +62,16 @@ export class RedisHelper {
55
62
  }
56
63
 
57
64
  async delObject(key) {
58
- return this.safeCall("delObject", async () => {
65
+ return this.strictCall("delObject", async () => {
59
66
  await this.client.del(`${this.prefix}${key}`);
60
67
  });
61
68
  }
62
69
 
63
70
  async setString(key, value, ttl = null) {
64
- return this.safeCall(
65
- "setString",
66
- async () => {
67
- const pkey = `${this.prefix}${key}`;
68
- return ttl ? this.client.setex(pkey, ttl, value) : this.client.set(pkey, value);
69
- },
70
- null
71
- );
71
+ return this.strictCall("setString", () => {
72
+ const pkey = `${this.prefix}${key}`;
73
+ return ttl ? this.client.setex(pkey, ttl, value) : this.client.set(pkey, value);
74
+ });
72
75
  }
73
76
 
74
77
  async getString(key) {
@@ -84,20 +87,16 @@ export class RedisHelper {
84
87
  }
85
88
 
86
89
  async incrWithExpire(key, seconds) {
87
- return this.safeCall(
88
- "incrWithExpire",
89
- async () => {
90
- const pkey = `${this.prefix}${key}`;
91
- const result = await this.client.incr(pkey);
92
- if (result === 1) await this.client.expire(pkey, seconds);
93
- return result;
94
- },
95
- 0
96
- );
90
+ // SET NX EX 先建键并带 TTL,再 INCR,保证计数键自创建起必有过期时间
91
+ return this.strictCall("incrWithExpire", async () => {
92
+ const pkey = `${this.prefix}${key}`;
93
+ await this.client.send("SET", [pkey, "0", "EX", String(seconds), "NX"]);
94
+ return this.client.incr(pkey);
95
+ });
97
96
  }
98
97
 
99
98
  async expire(key, seconds) {
100
- return this.safeCall("expire", () => this.client.expire(`${this.prefix}${key}`, seconds), 0);
99
+ return this.strictCall("expire", () => this.client.expire(`${this.prefix}${key}`, seconds));
101
100
  }
102
101
 
103
102
  async ttl(key) {
@@ -105,14 +104,10 @@ export class RedisHelper {
105
104
  }
106
105
 
107
106
  async sadd(key, members) {
108
- return this.safeCall(
109
- "sadd",
110
- async () => {
111
- if (members.length === 0) return 0;
112
- return this.client.sadd(`${this.prefix}${key}`, ...members);
113
- },
114
- 0
115
- );
107
+ return this.strictCall("sadd", async () => {
108
+ if (members.length === 0) return 0;
109
+ return this.client.sadd(`${this.prefix}${key}`, ...members);
110
+ });
116
111
  }
117
112
 
118
113
  async sismember(key, member) {
@@ -128,31 +123,23 @@ export class RedisHelper {
128
123
  }
129
124
 
130
125
  async saddBatch(items) {
131
- return this.safeCall(
132
- "saddBatch",
133
- async () => {
134
- if (items.length === 0) return 0;
135
- const results = await Promise.all(items.map((item) => this.sadd(item.key, item.members)));
136
- return results.reduce((sum, count) => sum + count, 0);
137
- },
138
- 0
139
- );
126
+ return this.strictCall("saddBatch", async () => {
127
+ if (items.length === 0) return 0;
128
+ const results = await Promise.all(items.map((item) => this.sadd(item.key, item.members)));
129
+ return results.reduce((sum, count) => sum + count, 0);
130
+ });
140
131
  }
141
132
 
142
133
  async del(key) {
143
- return this.safeCall("del", () => this.client.del(`${this.prefix}${key}`), 0);
134
+ return this.strictCall("del", () => this.client.del(`${this.prefix}${key}`));
144
135
  }
145
136
 
146
137
  async delBatch(keys) {
147
- return this.safeCall(
148
- "delBatch",
149
- async () => {
150
- if (keys.length === 0) return 0;
151
- const results = await Promise.all(keys.map((key) => this.client.del(`${this.prefix}${key}`)));
152
- return results.reduce((sum, count) => sum + count, 0);
153
- },
154
- 0
155
- );
138
+ return this.strictCall("delBatch", async () => {
139
+ if (keys.length === 0) return 0;
140
+ const results = await Promise.all(keys.map((key) => this.client.del(`${this.prefix}${key}`)));
141
+ return results.reduce((sum, count) => sum + count, 0);
142
+ });
156
143
  }
157
144
 
158
145
  async info(section) {
package/libs/smtpText.js CHANGED
@@ -30,7 +30,9 @@ function parseAddressList(value) {
30
30
  .map(function (item) {
31
31
  const trimmed = item.trim();
32
32
  const matched = /<([^<>]+)>/.exec(trimmed);
33
- return matched ? matched[1].trim() : trimmed;
33
+ // 收件人最终拼入 RCPT TO 命令,必须剥离 CR/LF 防 SMTP 命令注入
34
+ const address = matched ? matched[1].trim() : trimmed;
35
+ return address.replace(/[\r\n]+/g, "");
34
36
  })
35
37
  .filter(function (item) {
36
38
  return item.length > 0;
@@ -198,6 +200,20 @@ function writeCommand(socket, command) {
198
200
  socket.write(`${command}\r\n`);
199
201
  }
200
202
 
203
+ // 连接建立阶段默认 10 秒超时,避免网络黑洞下 sendMail 长时间挂起
204
+ function connectWithTimeout(options, timeoutMs) {
205
+ let timer;
206
+ const connectPromise = Bun.connect(options);
207
+ const timeoutPromise = new Promise((_, reject) => {
208
+ timer = setTimeout(() => {
209
+ reject(createError(`SMTP 连接超时(${timeoutMs}ms):${options.hostname}:${options.port}`, { code: "runtime", subsystem: "smtp", operation: "connect" }));
210
+ // 超时后才建立的连接直接关闭,避免孤儿 socket
211
+ connectPromise.then((late) => late.end()).catch(() => {});
212
+ }, timeoutMs);
213
+ });
214
+ return Promise.race([connectPromise, timeoutPromise]).finally(() => clearTimeout(timer));
215
+ }
216
+
201
217
  export async function sendSmtpTextMail(config, options) {
202
218
  const mail = createSmtpTextMessage(config, options);
203
219
  if (mail.recipients.length === 0) {
@@ -205,28 +221,31 @@ export async function sendSmtpTextMail(config, options) {
205
221
  }
206
222
 
207
223
  const reader = createResponseReader();
208
- const socket = await Bun.connect({
209
- hostname: config.host,
210
- port: config.port || 25,
211
- tls: normalizeSecureValue(config.secure),
212
- socket: {
213
- data: function (_socket, data) {
214
- reader.append(data);
215
- },
216
- close: function (_socket, error) {
217
- reader.close(error || null);
218
- },
219
- error: function (_socket, error) {
220
- reader.close(error);
221
- },
222
- connectError: function (_socket, error) {
223
- reader.close(error);
224
- },
225
- end: function () {
226
- reader.close(null);
224
+ const socket = await connectWithTimeout(
225
+ {
226
+ hostname: config.host,
227
+ port: config.port || 25,
228
+ tls: normalizeSecureValue(config.secure),
229
+ socket: {
230
+ data: function (_socket, data) {
231
+ reader.append(data);
232
+ },
233
+ close: function (_socket, error) {
234
+ reader.close(error || null);
235
+ },
236
+ error: function (_socket, error) {
237
+ reader.close(error);
238
+ },
239
+ connectError: function (_socket, error) {
240
+ reader.close(error);
241
+ },
242
+ end: function () {
243
+ reader.close(null);
244
+ }
227
245
  }
228
- }
229
- });
246
+ },
247
+ 10000
248
+ );
230
249
 
231
250
  try {
232
251
  const timeout = 10000;
@@ -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 中的字段");
@@ -44,11 +44,13 @@ export function parseNode(value, node, path, useDefault = true) {
44
44
  if (value === null) return failure([{ path: path, code: "null", message: "不允许为 null" }]);
45
45
 
46
46
  if (node.kind === "anyOf") {
47
+ const branchMessages = [];
47
48
  for (const branch of node.branches) {
48
49
  const result = parseNode(value, branch, path);
49
50
  if (result.ok) return result;
51
+ branchMessages.push(result.issues[0]?.message || typeMessage(branch.kind));
50
52
  }
51
- return failure([{ path: path, code: "anyOf", message: "不符合任一允许类型" }]);
53
+ return failure([{ path: path, code: "anyOf", message: `不符合任一允许类型:${branchMessages.join("")}` }]);
52
54
  }
53
55
 
54
56
  if (node.kind === "any") return success(value);
@@ -79,8 +81,9 @@ export function parseNode(value, node, path, useDefault = true) {
79
81
  }
80
82
 
81
83
  if (node.kind === "array") {
82
- if (node.minItem !== undefined && value.length < node.minItem) addIssue(issues, path, "minItem", `至少需要${node.minItem}项`);
83
- if (node.maxItem !== undefined && value.length > node.maxItem) addIssue(issues, path, "maxItem", `最多允许${node.maxItem}项`);
84
+ // 数量违规提前返回,避免后续对超大数组的遍历与拷贝
85
+ if (node.minItem !== undefined && value.length < node.minItem) return failure([{ path: path, code: "minItem", message: `至少需要${node.minItem}项` }]);
86
+ if (node.maxItem !== undefined && value.length > node.maxItem) return failure([{ path: path, code: "maxItem", message: `最多允许${node.maxItem}项` }]);
84
87
  if (node.unique && new Set(value).size !== value.length) addIssue(issues, path, "unique", "数组项不能重复");
85
88
  if (node.uniqueBy) {
86
89
  const seen = new Set();
@@ -97,7 +100,10 @@ export function parseNode(value, node, path, useDefault = true) {
97
100
  if (result.ok) data.push(result.data);
98
101
  else issues.push(...result.issues);
99
102
  });
100
- } else data.push(...value);
103
+ } else {
104
+ // 逐项拷贝而非 spread,避免超大数组触发调用栈溢出
105
+ for (const item of value) data.push(item);
106
+ }
101
107
  return issues.length > 0 ? failure(issues) : success(data);
102
108
  }
103
109
 
@@ -1,5 +1 @@
1
- export function isPlainObject(value) {
2
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
3
- const prototype = Object.getPrototypeOf(value);
4
- return prototype === Object.prototype || prototype === null;
5
- }
1
+ export { isPlainObject } from "../../utils/is.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "befly",
3
- "version": "3.76.6",
3
+ "version": "3.77.0",
4
4
  "gitHead": "49c39d36695036e85fc64083cc43c1652fff96cb",
5
5
  "private": false,
6
6
  "description": "Befly - 为 Bun 专属打造的 JavaScript API 接口框架核心引擎",
package/paths.js CHANGED
@@ -10,7 +10,8 @@
10
10
  *
11
11
  */
12
12
 
13
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
13
+ import { existsSync } from "node:fs";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
 
16
17
  // 当前文件的路径信息
@@ -25,12 +26,6 @@ const moduleDir = dirname(moduleFilePath);
25
26
  */
26
27
  export const coreDir = moduleDir;
27
28
 
28
- /**
29
- * Core 框架 dist 目录
30
- * @description 源码态为 packages/core/dist;dist 运行态为 packages/core/dist
31
- */
32
- export const coreDistDir = basename(moduleDir) === "dist" ? moduleDir : join(moduleDir, "dist");
33
-
34
29
  /**
35
30
  * Core 框架检查目录
36
31
  * @description packages/core/checks/
@@ -75,12 +70,30 @@ export const coreTableDir = join(moduleDir, "tables");
75
70
 
76
71
  // ==================== 用户项目路径 ====================
77
72
 
73
+ /**
74
+ * 定位项目根目录:从 cwd 逐级向上查找 bm2.toml(项目根标识文件)。
75
+ * 找不到时回退 cwd——此时项目源码目录(apis/tables 等)将扫描不到,
76
+ * 由 scanSources 在启动期对"app 源码目录全部缺失"的场景统一告警。
77
+ */
78
+ function findAppRoot() {
79
+ let dir = process.cwd();
80
+ for (;;) {
81
+ if (existsSync(join(dir, "bm2.toml"))) {
82
+ return dir;
83
+ }
84
+ const parent = dirname(dir);
85
+ if (parent === dir) {
86
+ return process.cwd();
87
+ }
88
+ dir = parent;
89
+ }
90
+ }
91
+
78
92
  /**
79
93
  * 项目根目录
80
- * @description process.cwd()
81
- * @usage 用户项目的根目录
94
+ * @description 含 bm2.toml 的目录
82
95
  */
83
- export const appDir = process.cwd();
96
+ export const appDir = findAppRoot();
84
97
 
85
98
  /**
86
99
  * 项目检查目录
package/router/static.js CHANGED
@@ -62,14 +62,21 @@ export function staticHandler(corsConfig, uploadConfig) {
62
62
 
63
63
  const file = Bun.file(filePath);
64
64
  if (await file.exists()) {
65
- const headers = {};
66
- if (corsHeaders && typeof corsHeaders === "object") {
67
- for (const key of Object.keys(corsHeaders)) {
68
- headers[key] = corsHeaders[key];
69
- }
70
- }
65
+ const headers = { ...corsHeaders };
71
66
  headers["Content-Type"] = file.type || "application/octet-stream";
72
67
  headers["X-Content-Type-Options"] = "nosniff";
68
+
69
+ // ETag 协商缓存:命中 If-None-Match 直接返回 304,避免重复传输文件体
70
+ const etag = `"${file.size}-${file.lastModified}"`;
71
+ headers["ETag"] = etag;
72
+ headers["Cache-Control"] = "public, max-age=86400";
73
+ if (req.headers.get("if-none-match") === etag) {
74
+ return new Response(null, {
75
+ status: 304,
76
+ headers: headers
77
+ });
78
+ }
79
+
73
80
  if (uploadConfig.forceDownloadExtensions.split(",").includes(extname(filePath).toLowerCase())) {
74
81
  headers["Content-Disposition"] = `attachment; filename="${basename(filePath).replaceAll('"', "")}"`;
75
82
  }
package/schemas/api.json CHANGED
@@ -52,6 +52,10 @@
52
52
  "paramType": "string",
53
53
  "minValue": 1
54
54
  }
55
+ },
56
+ {
57
+ "paramType": "string",
58
+ "enum": ["dev"]
55
59
  }
56
60
  ]
57
61
  },
@@ -248,6 +248,26 @@
248
248
  "items": {
249
249
  "paramType": "string"
250
250
  }
251
+ },
252
+ "rules": {
253
+ "paramType": "array",
254
+ "items": {
255
+ "paramType": "object",
256
+ "fields": {
257
+ "path": {
258
+ "paramType": "string",
259
+ "minValue": 1
260
+ },
261
+ "limit": {
262
+ "paramType": "integer",
263
+ "minValue": 1
264
+ },
265
+ "window": {
266
+ "paramType": "integer",
267
+ "minValue": 1
268
+ }
269
+ }
270
+ }
251
271
  }
252
272
  }
253
273
  }
package/sync/api.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { BEFLY_API_TABLE } from "../configs/constConfig.js";
2
2
  import { buildPathSyncLists, serializeAuth } from "./syncUtil.js";
3
3
 
4
+ // insBatch 单批上限 1000 行,超出分批写入
5
+ async function insBatchChunked(mysql, table, list) {
6
+ for (let start = 0; start < list.length; start += 1000) {
7
+ await mysql.insBatch(table, list.slice(start, start + 1000));
8
+ }
9
+ }
10
+
4
11
  const getApiParentPath = (apiPath) => {
5
12
  const segments = apiPath
6
13
  .split("/")
@@ -19,7 +26,7 @@ const getApiParentPath = (apiPath) => {
19
26
  export async function syncApi(ctx, apis) {
20
27
  const allDbApis = await ctx.mysql.getAll({
21
28
  table: BEFLY_API_TABLE,
22
- fields: ["id", "path", "parentPath", "name", "auth", "state"],
29
+ fields: ["id", "path", "parentPath", "name", "method", "auth", "category", "state"],
23
30
  where: { state$gte: 0 }
24
31
  });
25
32
 
@@ -30,7 +37,8 @@ export async function syncApi(ctx, apis) {
30
37
  path: api.apiPath,
31
38
  method: api.method,
32
39
  parentPath: getApiParentPath(api.apiPath),
33
- auth: serializeAuth(api.auth)
40
+ auth: serializeAuth(api.auth),
41
+ category: api.category || "other"
34
42
  });
35
43
  }
36
44
 
@@ -42,14 +50,16 @@ export async function syncApi(ctx, apis) {
42
50
  path: def.path,
43
51
  method: def.method,
44
52
  parentPath: def.parentPath,
45
- auth: def.auth
53
+ auth: def.auth,
54
+ category: def.category
46
55
  }),
47
56
  (def) => ({
48
57
  name: def.name,
49
58
  path: def.path,
50
59
  method: def.method,
51
60
  parentPath: def.parentPath,
52
- auth: def.auth
61
+ auth: def.auth,
62
+ category: def.category
53
63
  })
54
64
  );
55
65
 
@@ -57,9 +67,7 @@ export async function syncApi(ctx, apis) {
57
67
  await ctx.mysql.updBatch(BEFLY_API_TABLE, syncLists.updList);
58
68
  }
59
69
 
60
- if (syncLists.insList.length > 0) {
61
- await ctx.mysql.insBatch(BEFLY_API_TABLE, syncLists.insList);
62
- }
70
+ await insBatchChunked(ctx.mysql, BEFLY_API_TABLE, syncLists.insList);
63
71
 
64
72
  if (syncLists.delIds.length > 0) {
65
73
  await ctx.mysql.delForceBatch(BEFLY_API_TABLE, syncLists.delIds);
package/sync/dev.js CHANGED
@@ -38,6 +38,12 @@ export async function syncDev(ctx) {
38
38
  where: { username: "dev", state$gte: 0 }
39
39
  });
40
40
 
41
+ const adminAccount = await ctx.mysql.getOne({
42
+ table: BEFLY_ADMIN_TABLE,
43
+ fields: ["id"],
44
+ where: { username: "admin", state$gte: 0 }
45
+ });
46
+
41
47
  const menuPaths = [];
42
48
  for (const item of allMenus.data.lists) {
43
49
  menuPaths.push(item.path);
@@ -72,7 +78,6 @@ export async function syncDev(ctx) {
72
78
 
73
79
  // devPassword 为必填项(校验见函数开头),此处直接使用
74
80
  const password = await hashPassword(ctx.config.devPassword);
75
- // 【临时诊断】hashPassword 后连接预检
76
81
 
77
82
  const devAdminData = {
78
83
  nickname: "开发者",
@@ -92,7 +97,24 @@ export async function syncDev(ctx) {
92
97
  } else {
93
98
  await ctx.mysql.insData({
94
99
  table: BEFLY_ADMIN_TABLE,
95
- data: { ...devAdminData, state: 1 }
100
+ data: devAdminData
101
+ });
102
+ }
103
+
104
+ // admin 管理员账号:仅首次创建,已存在则不覆盖(密码与资料以库内为准,改密后重启不受影响)
105
+ // 初始密码为固定默认值 admin123456,首次登录后应立即修改
106
+ if (!isNumber(adminAccount.data.id)) {
107
+ await ctx.mysql.insData({
108
+ table: BEFLY_ADMIN_TABLE,
109
+ data: {
110
+ nickname: "管理员",
111
+ email: "admin@qq.com",
112
+ username: "admin",
113
+ password: await hashPassword("admin123456"),
114
+ roleCode: "admin",
115
+ roleType: "admin",
116
+ state: 1
117
+ }
96
118
  });
97
119
  }
98
120
 
@@ -141,6 +163,8 @@ export async function syncDev(ctx) {
141
163
 
142
164
  const mergedApis = [];
143
165
  const mergedApiSet = new Set();
166
+ // 仅保留当前仍存在的接口路径,已删除/改名的旧 path 不再永久残留
167
+ const validApiSet = new Set(allApis.data.lists.map((api) => api.path));
144
168
 
145
169
  if (Array.isArray(existingRole.data?.apis)) {
146
170
  for (const apiPath of existingRole.data.apis) {
@@ -148,7 +172,7 @@ export async function syncDev(ctx) {
148
172
  continue;
149
173
  }
150
174
  const value = apiPath.trim();
151
- if (value.length === 0 || mergedApiSet.has(value)) {
175
+ if (value.length === 0 || !validApiSet.has(value) || mergedApiSet.has(value)) {
152
176
  continue;
153
177
  }
154
178
  mergedApiSet.add(value);
package/sync/menu.js CHANGED
@@ -40,7 +40,7 @@ export function flattenMenusToDefMap(menus) {
40
40
  export async function syncMenu(ctx, menus) {
41
41
  const menuDefMap = flattenMenusToDefMap(menus);
42
42
 
43
- // 2) 批量同步(事务内):按 path diff 执行批量 insert/update/delete
43
+ // 2) 批量同步:按 path diff 执行批量 insert/update/delete(幂等收敛,非原子事务)
44
44
  // 读取全部菜单
45
45
  const allExistingMenus = await ctx.mysql.getAll({
46
46
  table: BEFLY_MENU_TABLE,
@@ -69,8 +69,8 @@ export async function syncMenu(ctx, menus) {
69
69
  await ctx.mysql.updBatch(BEFLY_MENU_TABLE, syncLists.updList);
70
70
  }
71
71
 
72
- if (syncLists.insList.length > 0) {
73
- await ctx.mysql.insBatch(BEFLY_MENU_TABLE, syncLists.insList);
72
+ for (let start = 0; start < syncLists.insList.length; start += 1000) {
73
+ await ctx.mysql.insBatch(BEFLY_MENU_TABLE, syncLists.insList.slice(start, start + 1000));
74
74
  }
75
75
 
76
76
  // 3) 删除差集(DB - 配置)
package/sync/syncUtil.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // 接口 auth 的存储协议(serializeAuth 与 authAllowsRole 配套使用):
2
2
  // - true -> 1(需登录)
3
3
  // - false -> 0(免登录)
4
+ // - "dev" -> "dev"(超级管理员专属:管理员表中 roleCode 为 dev/admin 的账号,不参与角色授权)
4
5
  // - string[] -> "a,b,c"(角色类型白名单,如 admin/user)
5
6
  export function serializeAuth(auth) {
6
- return auth === false ? 0 : Array.isArray(auth) ? auth.join(",") : 1;
7
+ return auth === false ? 0 : auth === "dev" ? "dev" : Array.isArray(auth) ? auth.join(",") : 1;
7
8
  }
8
9
 
9
10
  /**
@@ -17,6 +18,10 @@ export function authAllowsRole(auth, roleCode) {
17
18
  return false;
18
19
  }
19
20
 
21
+ function isSameRecord(data, existing) {
22
+ return Object.entries(data).every(([key, value]) => existing[key] === value);
23
+ }
24
+
20
25
  export function buildPathSyncLists(existingList, defList, buildUpdateData, buildInsertData) {
21
26
  const existingMap = new Map();
22
27
  const delIdSet = new Set();
@@ -24,8 +29,9 @@ export function buildPathSyncLists(existingList, defList, buildUpdateData, build
24
29
  for (const record of existingList) {
25
30
  if (!existingMap.has(record.path)) {
26
31
  existingMap.set(record.path, record);
27
- delIdSet.add(record.id);
28
32
  }
33
+ // 全部行先进删除集,命中配置的再移出;同 path 重复行(历史并发同步产物)因此按孤儿清理
34
+ delIdSet.add(record.id);
29
35
  }
30
36
 
31
37
  const updList = [];
@@ -36,10 +42,10 @@ export function buildPathSyncLists(existingList, defList, buildUpdateData, build
36
42
 
37
43
  if (existing) {
38
44
  delIdSet.delete(existing.id);
39
- updList.push({
40
- id: existing.id,
41
- data: buildUpdateData(def, existing)
42
- });
45
+ const data = buildUpdateData(def, existing);
46
+ if (!isSameRecord(data, existing)) {
47
+ updList.push({ id: existing.id, data: data });
48
+ }
43
49
  continue;
44
50
  }
45
51
 
package/tables/api.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "$tableName": "接口表",
3
+ "$tableIndexes": {
4
+ "uk_befly_api_path": "unique#path"
5
+ },
3
6
  "name": {
4
7
  "name": "接口名称",
5
8
  "minValue": 2,
@@ -17,6 +20,15 @@
17
20
  "fieldType": "varchar",
18
21
  "fieldDefault": ""
19
22
  },
23
+ "category": {
24
+ "name": "接口分类",
25
+ "detail": "select=查询类,other=其他",
26
+ "minValue": 1,
27
+ "maxValue": 50,
28
+ "paramType": "string",
29
+ "fieldType": "varchar",
30
+ "fieldDefault": "other"
31
+ },
20
32
  "method": {
21
33
  "name": "请求方式",
22
34
  "minValue": 1,