befly 3.9.40 → 3.10.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 (141) hide show
  1. package/README.md +39 -8
  2. package/befly.config.ts +19 -2
  3. package/checks/checkApi.ts +79 -77
  4. package/checks/checkHook.ts +48 -0
  5. package/checks/checkMenu.ts +168 -0
  6. package/checks/checkPlugin.ts +48 -0
  7. package/checks/checkTable.ts +137 -183
  8. package/docs/README.md +1 -1
  9. package/docs/api/api.md +1 -1
  10. package/docs/guide/quickstart.md +16 -9
  11. package/docs/hooks/hook.md +2 -2
  12. package/docs/hooks/rateLimit.md +1 -1
  13. package/docs/infra/redis.md +7 -7
  14. package/docs/plugins/plugin.md +23 -21
  15. package/docs/quickstart.md +16 -9
  16. package/docs/reference/addon.md +12 -1
  17. package/docs/reference/config.md +13 -30
  18. package/docs/reference/sync.md +62 -193
  19. package/docs/reference/table.md +27 -29
  20. package/hooks/auth.ts +3 -4
  21. package/hooks/cors.ts +4 -6
  22. package/hooks/parser.ts +3 -4
  23. package/hooks/permission.ts +3 -4
  24. package/hooks/validator.ts +3 -4
  25. package/lib/cacheHelper.ts +89 -153
  26. package/lib/cacheKeys.ts +1 -1
  27. package/lib/connect.ts +9 -13
  28. package/lib/dbDialect.ts +285 -0
  29. package/lib/dbHelper.ts +179 -507
  30. package/lib/dbUtils.ts +450 -0
  31. package/lib/logger.ts +41 -5
  32. package/lib/redisHelper.ts +1 -0
  33. package/lib/sqlBuilder.ts +358 -58
  34. package/lib/sqlCheck.ts +136 -0
  35. package/lib/validator.ts +1 -1
  36. package/loader/loadApis.ts +23 -126
  37. package/loader/loadHooks.ts +31 -46
  38. package/loader/loadPlugins.ts +37 -52
  39. package/main.ts +58 -19
  40. package/package.json +24 -25
  41. package/paths.ts +14 -14
  42. package/plugins/cache.ts +12 -6
  43. package/plugins/cipher.ts +2 -2
  44. package/plugins/config.ts +6 -8
  45. package/plugins/db.ts +14 -19
  46. package/plugins/jwt.ts +6 -7
  47. package/plugins/logger.ts +7 -9
  48. package/plugins/redis.ts +8 -10
  49. package/plugins/tool.ts +3 -4
  50. package/router/api.ts +3 -2
  51. package/router/static.ts +7 -5
  52. package/sync/syncApi.ts +80 -235
  53. package/sync/syncCache.ts +16 -0
  54. package/sync/syncDev.ts +167 -202
  55. package/sync/syncMenu.ts +230 -444
  56. package/sync/syncTable.ts +1247 -0
  57. package/tests/_mocks/mockSqliteDb.ts +204 -0
  58. package/tests/addonHelper-cache.test.ts +32 -0
  59. package/tests/apiHandler-routePath-only.test.ts +32 -0
  60. package/tests/cacheHelper.test.ts +16 -51
  61. package/tests/checkApi-routePath-strict.test.ts +166 -0
  62. package/tests/checkMenu.test.ts +346 -0
  63. package/tests/checkTable-smoke.test.ts +157 -0
  64. package/tests/dbDialect-cache.test.ts +23 -0
  65. package/tests/dbDialect.test.ts +46 -0
  66. package/tests/dbHelper-advanced.test.ts +1 -1
  67. package/tests/dbHelper-all-array-types.test.ts +15 -15
  68. package/tests/dbHelper-batch-write.test.ts +90 -0
  69. package/tests/dbHelper-columns.test.ts +36 -54
  70. package/tests/dbHelper-execute.test.ts +26 -26
  71. package/tests/dbHelper-joins.test.ts +85 -176
  72. package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
  73. package/tests/fixtures/scanFilesApis/a.ts +3 -0
  74. package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
  75. package/tests/loadPlugins-order-smoke.test.ts +75 -0
  76. package/tests/logger.test.ts +6 -6
  77. package/tests/redisHelper.test.ts +6 -1
  78. package/tests/scanFiles-routePath.test.ts +46 -0
  79. package/tests/smoke-sql.test.ts +24 -0
  80. package/tests/sqlBuilder-advanced.test.ts +18 -5
  81. package/tests/sqlBuilder.test.ts +24 -0
  82. package/tests/sync-init-guard.test.ts +105 -0
  83. package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
  84. package/tests/syncApi-obsolete-records.test.ts +69 -0
  85. package/tests/syncApi-type-compat.test.ts +72 -0
  86. package/tests/syncDev-permissions.test.ts +81 -0
  87. package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
  88. package/tests/syncMenu-duplicate-path.test.ts +122 -0
  89. package/tests/syncMenu-obsolete-records.test.ts +161 -0
  90. package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
  91. package/tests/syncMenu-paths.test.ts +0 -9
  92. package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
  93. package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
  94. package/tests/syncTable-constants.test.ts +101 -0
  95. package/tests/syncTable-db-integration.test.ts +237 -0
  96. package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
  97. package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
  98. package/tests/syncTable-schema.test.ts +99 -0
  99. package/tests/syncTable-testkit.test.ts +25 -0
  100. package/tests/syncTable-types.test.ts +122 -0
  101. package/tests/tableRef-and-deserialize.test.ts +67 -0
  102. package/tsconfig.json +1 -1
  103. package/types/api.d.ts +1 -1
  104. package/types/befly.d.ts +13 -12
  105. package/types/cache.d.ts +2 -2
  106. package/types/context.d.ts +1 -1
  107. package/types/database.d.ts +0 -5
  108. package/types/hook.d.ts +1 -10
  109. package/types/plugin.d.ts +2 -96
  110. package/types/sync.d.ts +19 -25
  111. package/utils/convertBigIntFields.ts +38 -0
  112. package/utils/disableMenusGlob.ts +85 -0
  113. package/utils/importDefault.ts +21 -0
  114. package/utils/isDirentDirectory.ts +23 -0
  115. package/utils/loadMenuConfigs.ts +145 -0
  116. package/utils/processFields.ts +25 -0
  117. package/utils/scanAddons.ts +72 -0
  118. package/utils/scanFiles.ts +129 -21
  119. package/utils/scanSources.ts +64 -0
  120. package/utils/sortModules.ts +137 -0
  121. package/checks/checkApp.ts +0 -55
  122. package/hooks/rateLimit.ts +0 -276
  123. package/sync/syncAll.ts +0 -35
  124. package/sync/syncDb/apply.ts +0 -192
  125. package/sync/syncDb/constants.ts +0 -119
  126. package/sync/syncDb/ddl.ts +0 -251
  127. package/sync/syncDb/helpers.ts +0 -84
  128. package/sync/syncDb/schema.ts +0 -202
  129. package/sync/syncDb/sqlite.ts +0 -48
  130. package/sync/syncDb/table.ts +0 -207
  131. package/sync/syncDb/tableCreate.ts +0 -163
  132. package/sync/syncDb/types.ts +0 -132
  133. package/sync/syncDb/version.ts +0 -69
  134. package/sync/syncDb.ts +0 -168
  135. package/tests/rateLimit-hook.test.ts +0 -477
  136. package/tests/syncDb-constants.test.ts +0 -130
  137. package/tests/syncDb-schema.test.ts +0 -179
  138. package/tests/syncDb-types.test.ts +0 -139
  139. package/utils/addonHelper.ts +0 -90
  140. package/utils/modules.ts +0 -98
  141. package/utils/route.ts +0 -23
package/lib/dbUtils.ts ADDED
@@ -0,0 +1,450 @@
1
+ import type { WhereConditions } from "../types/common.js";
2
+
3
+ import { snakeCase } from "es-toolkit/string";
4
+
5
+ import { fieldClear } from "../utils/fieldClear.js";
6
+ import { keysToSnake } from "../utils/keysToSnake.js";
7
+
8
+ export class DbUtils {
9
+ static parseTableRef(tableRef: string): { schema: string | null; table: string; alias: string | null } {
10
+ if (typeof tableRef !== "string") {
11
+ throw new Error(`tableRef 必须是字符串 (tableRef: ${String(tableRef)})`);
12
+ }
13
+
14
+ const trimmed = tableRef.trim();
15
+ if (!trimmed) {
16
+ throw new Error("tableRef 不能为空");
17
+ }
18
+
19
+ const parts = trimmed.split(/\s+/).filter((p) => p.length > 0);
20
+ if (parts.length > 2) {
21
+ throw new Error(`不支持的表引用格式(包含过多片段)。请使用最简形式:table 或 table alias 或 schema.table 或 schema.table alias (tableRef: ${trimmed})`);
22
+ }
23
+
24
+ const namePart = parts[0];
25
+ const aliasPart = parts.length === 2 ? parts[1] : null;
26
+
27
+ const nameSegments = namePart.split(".");
28
+ if (nameSegments.length > 2) {
29
+ throw new Error(`不支持的表引用格式(schema 层级过深) (tableRef: ${trimmed})`);
30
+ }
31
+
32
+ const schema = nameSegments.length === 2 ? nameSegments[0] : null;
33
+ const table = nameSegments.length === 2 ? nameSegments[1] : nameSegments[0];
34
+
35
+ return { schema: schema, table: table, alias: aliasPart };
36
+ }
37
+
38
+ /**
39
+ * 规范化表引用:只 snakeCase schema/table,本身 alias 保持原样。
40
+ * - 支持:table / table alias / schema.table / schema.table alias
41
+ */
42
+ static normalizeTableRef(tableRef: string): string {
43
+ const parsed = DbUtils.parseTableRef(tableRef);
44
+
45
+ const schemaPart = parsed.schema ? snakeCase(parsed.schema) : null;
46
+ const tablePart = snakeCase(parsed.table);
47
+
48
+ let result = schemaPart ? `${schemaPart}.${tablePart}` : tablePart;
49
+ if (parsed.alias) {
50
+ result = `${result} ${parsed.alias}`;
51
+ }
52
+
53
+ return result;
54
+ }
55
+
56
+ /**
57
+ * JOIN 场景下主表的限定符:优先使用 alias;没有 alias 时使用 snakeCase(table)。
58
+ * 用于构造类似 "o.state$gt" 的 where key,避免出现 "order o.state$gt" 这种带空格的非法 key。
59
+ */
60
+ static getJoinMainQualifier(tableRef: string): string {
61
+ const parsed = DbUtils.parseTableRef(tableRef);
62
+ if (parsed.alias) {
63
+ return parsed.alias;
64
+ }
65
+ return snakeCase(parsed.table);
66
+ }
67
+
68
+ /**
69
+ * 字段数组转下划线格式
70
+ * 支持排除字段语法:['!password', '!token']
71
+ *
72
+ * 说明:exclude 模式需要表的所有字段名,因此通过 getTableColumns 回调获取
73
+ */
74
+ static async fieldsToSnake(table: string, fields: string[], getTableColumns: (table: string) => Promise<string[]>): Promise<string[]> {
75
+ if (!fields || !Array.isArray(fields)) {
76
+ return ["*"];
77
+ }
78
+
79
+ const classified = DbUtils.validateAndClassifyFields(fields);
80
+
81
+ // 情况1:查询所有字段
82
+ if (classified.type === "all") {
83
+ return ["*"];
84
+ }
85
+
86
+ // 情况2:指定包含字段
87
+ if (classified.type === "include") {
88
+ return classified.fields.map((field) => {
89
+ // 保留函数和特殊字段
90
+ if (field.includes("(") || field.includes(" ")) {
91
+ return field;
92
+ }
93
+ return snakeCase(field);
94
+ });
95
+ }
96
+
97
+ // 情况3:排除字段
98
+ if (classified.type === "exclude") {
99
+ const allColumns = await getTableColumns(table);
100
+ const excludeSnakeFields = classified.fields.map((f) => snakeCase(f));
101
+
102
+ const resultFields = allColumns.filter((col) => !excludeSnakeFields.includes(col));
103
+ if (resultFields.length === 0) {
104
+ throw new Error(`排除字段后没有剩余字段可查询。表: ${table}, 排除: ${excludeSnakeFields.join(", ")}`);
105
+ }
106
+
107
+ return resultFields;
108
+ }
109
+
110
+ return ["*"];
111
+ }
112
+
113
+ static validateAndClassifyFields(fields?: string[]): {
114
+ type: "all" | "include" | "exclude";
115
+ fields: string[];
116
+ } {
117
+ // 情况1:空数组或 undefined,表示查询所有
118
+ if (!fields || fields.length === 0) {
119
+ return { type: "all", fields: [] };
120
+ }
121
+
122
+ // 检测是否有星号(禁止)
123
+ if (fields.some((f) => f === "*")) {
124
+ throw new Error("fields 不支持 * 星号,请使用空数组 [] 或不传参数表示查询所有字段");
125
+ }
126
+
127
+ // 检测是否有空字符串或无效值
128
+ if (fields.some((f) => !f || typeof f !== "string" || f.trim() === "")) {
129
+ throw new Error("fields 不能包含空字符串或无效值");
130
+ }
131
+
132
+ // 统计包含字段和排除字段
133
+ const includeFields = fields.filter((f) => !f.startsWith("!"));
134
+ const excludeFields = fields.filter((f) => f.startsWith("!"));
135
+
136
+ // 情况2:全部是包含字段
137
+ if (includeFields.length > 0 && excludeFields.length === 0) {
138
+ return { type: "include", fields: includeFields };
139
+ }
140
+
141
+ // 情况3:全部是排除字段
142
+ if (excludeFields.length > 0 && includeFields.length === 0) {
143
+ // 去掉感叹号前缀
144
+ const cleanExcludeFields = excludeFields.map((f) => f.substring(1));
145
+ return { type: "exclude", fields: cleanExcludeFields };
146
+ }
147
+
148
+ // 混用情况:报错
149
+ throw new Error('fields 不能同时包含普通字段和排除字段(! 开头)。只能使用以下3种方式之一:\n1. 空数组 [] 或不传(查询所有)\n2. 全部指定字段 ["id", "name"]\n3. 全部排除字段 ["!password", "!token"]');
150
+ }
151
+
152
+ static orderByToSnake(orderBy: string[]): string[] {
153
+ if (!orderBy || !Array.isArray(orderBy)) {
154
+ return orderBy;
155
+ }
156
+
157
+ return orderBy.map((item) => {
158
+ if (typeof item !== "string" || !item.includes("#")) {
159
+ return item;
160
+ }
161
+
162
+ const [field, direction] = item.split("#");
163
+ return `${snakeCase(field.trim())}#${direction.trim()}`;
164
+ });
165
+ }
166
+
167
+ static processJoinField(field: string): string {
168
+ // 跳过函数、星号、已处理的字段
169
+ if (field.includes("(") || field === "*" || field.startsWith("`")) {
170
+ return field;
171
+ }
172
+
173
+ // 处理别名 AS
174
+ if (field.toUpperCase().includes(" AS ")) {
175
+ const parts = field.split(/\s+AS\s+/i);
176
+ const fieldPart = parts[0].trim();
177
+ const aliasPart = parts[1].trim();
178
+ return `${DbUtils.processJoinField(fieldPart)} AS ${aliasPart}`;
179
+ }
180
+
181
+ // 处理表别名.字段名(JOIN 模式下,点号前面通常是别名,不应被 snakeCase 改写)
182
+ if (field.includes(".")) {
183
+ const parts = field.split(".");
184
+ const tableName = parts[0];
185
+ const fieldName = parts[1];
186
+ return `${tableName.trim()}.${snakeCase(fieldName)}`;
187
+ }
188
+
189
+ // 普通字段
190
+ return snakeCase(field);
191
+ }
192
+
193
+ static processJoinWhereKey(key: string): string {
194
+ // 保留逻辑操作符
195
+ if (key === "$or" || key === "$and") {
196
+ return key;
197
+ }
198
+
199
+ // 处理带操作符的字段名(如 user.userId$gt)
200
+ if (key.includes("$")) {
201
+ const lastDollarIndex = key.lastIndexOf("$");
202
+ const fieldPart = key.substring(0, lastDollarIndex);
203
+ const operator = key.substring(lastDollarIndex);
204
+
205
+ if (fieldPart.includes(".")) {
206
+ const parts = fieldPart.split(".");
207
+ const tableName = parts[0];
208
+ const fieldName = parts[1];
209
+ return `${tableName.trim()}.${snakeCase(fieldName)}${operator}`;
210
+ }
211
+
212
+ return `${snakeCase(fieldPart)}${operator}`;
213
+ }
214
+
215
+ // 处理表名.字段名
216
+ if (key.includes(".")) {
217
+ const parts = key.split(".");
218
+ const tableName = parts[0];
219
+ const fieldName = parts[1];
220
+ return `${tableName.trim()}.${snakeCase(fieldName)}`;
221
+ }
222
+
223
+ // 普通字段
224
+ return snakeCase(key);
225
+ }
226
+
227
+ static processJoinWhere(where: any): any {
228
+ if (!where || typeof where !== "object") {
229
+ return where;
230
+ }
231
+
232
+ if (Array.isArray(where)) {
233
+ return where.map((item) => DbUtils.processJoinWhere(item));
234
+ }
235
+
236
+ const result: any = {};
237
+ for (const [key, value] of Object.entries(where)) {
238
+ const newKey = DbUtils.processJoinWhereKey(key);
239
+
240
+ if (key === "$or" || key === "$and") {
241
+ result[newKey] = (value as any[]).map((item) => DbUtils.processJoinWhere(item));
242
+ } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
243
+ result[newKey] = DbUtils.processJoinWhere(value);
244
+ } else {
245
+ result[newKey] = value;
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
251
+
252
+ static processJoinOrderBy(orderBy: string[]): string[] {
253
+ if (!orderBy || !Array.isArray(orderBy)) {
254
+ return orderBy;
255
+ }
256
+
257
+ return orderBy.map((item) => {
258
+ if (typeof item !== "string" || !item.includes("#")) {
259
+ return item;
260
+ }
261
+
262
+ const [field, direction] = item.split("#");
263
+ return `${DbUtils.processJoinField(field.trim())}#${direction.trim()}`;
264
+ });
265
+ }
266
+
267
+ static addDefaultStateFilter(where: WhereConditions = {}, table?: string, hasJoins: boolean = false): WhereConditions {
268
+ // 如果用户已经指定了 state 条件,优先使用用户的条件
269
+ const hasStateCondition = Object.keys(where).some((key) => key.startsWith("state") || key.includes(".state"));
270
+
271
+ if (hasStateCondition) {
272
+ return where;
273
+ }
274
+
275
+ // JOIN 查询时需要指定主表名前缀避免歧义
276
+ if (hasJoins && table) {
277
+ // table 可能带别名("order o"),这里只需要别名/主表引用本身,不做 snakeCase 改写
278
+ const result: any = {};
279
+ for (const [key, value] of Object.entries(where)) {
280
+ result[key] = value;
281
+ }
282
+ result[`${table}.state$gt`] = 0;
283
+ return result;
284
+ }
285
+
286
+ // 默认查询 state > 0 的数据
287
+ const result: any = {};
288
+ for (const [key, value] of Object.entries(where)) {
289
+ result[key] = value;
290
+ }
291
+ result.state$gt = 0;
292
+ return result;
293
+ }
294
+
295
+ /**
296
+ * Where 条件键名转下划线格式(递归处理嵌套)
297
+ * 支持操作符字段(如 userId$gt)和逻辑操作符($or, $and)
298
+ */
299
+ static whereKeysToSnake(where: any): any {
300
+ if (!where || typeof where !== "object") {
301
+ return where;
302
+ }
303
+
304
+ // 处理数组($or, $and 等)
305
+ if (Array.isArray(where)) {
306
+ return where.map((item) => DbUtils.whereKeysToSnake(item));
307
+ }
308
+
309
+ const result: any = {};
310
+ for (const [key, value] of Object.entries(where)) {
311
+ // 保留 $or, $and 等逻辑操作符
312
+ if (key === "$or" || key === "$and") {
313
+ result[key] = (value as any[]).map((item) => DbUtils.whereKeysToSnake(item));
314
+ continue;
315
+ }
316
+
317
+ // 处理带操作符的字段名(如 userId$gt)
318
+ if (key.includes("$")) {
319
+ const lastDollarIndex = key.lastIndexOf("$");
320
+ const fieldName = key.substring(0, lastDollarIndex);
321
+ const operator = key.substring(lastDollarIndex);
322
+ const snakeKey = snakeCase(fieldName) + operator;
323
+ result[snakeKey] = value;
324
+ continue;
325
+ }
326
+
327
+ // 普通字段:转换键名,递归处理值(支持嵌套对象)
328
+ const snakeKey = snakeCase(key);
329
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
330
+ result[snakeKey] = DbUtils.whereKeysToSnake(value);
331
+ } else {
332
+ result[snakeKey] = value;
333
+ }
334
+ }
335
+
336
+ return result;
337
+ }
338
+
339
+ /**
340
+ * 序列化数组字段(写入数据库前)
341
+ * 将数组类型的字段转换为 JSON 字符串
342
+ */
343
+ static serializeArrayFields(data: Record<string, any>): Record<string, any> {
344
+ const serialized: Record<string, any> = {};
345
+
346
+ for (const [key, value] of Object.entries(data)) {
347
+ if (value === null || value === undefined) {
348
+ serialized[key] = value;
349
+ continue;
350
+ }
351
+
352
+ if (Array.isArray(value)) {
353
+ serialized[key] = JSON.stringify(value);
354
+ continue;
355
+ }
356
+
357
+ serialized[key] = value;
358
+ }
359
+
360
+ return serialized;
361
+ }
362
+
363
+ /**
364
+ * 反序列化数组字段(从数据库读取后)
365
+ * 将 JSON 字符串转换回数组
366
+ */
367
+ static deserializeArrayFields<T = any>(data: Record<string, any> | null): T | null {
368
+ if (!data) {
369
+ return null;
370
+ }
371
+
372
+ const deserialized: Record<string, any> = {};
373
+ for (const [key, value] of Object.entries(data)) {
374
+ deserialized[key] = value;
375
+ }
376
+
377
+ for (const [key, value] of Object.entries(deserialized)) {
378
+ if (typeof value !== "string") {
379
+ continue;
380
+ }
381
+
382
+ if (value.startsWith("[") && value.endsWith("]")) {
383
+ try {
384
+ const parsed = JSON.parse(value);
385
+ if (Array.isArray(parsed)) {
386
+ deserialized[key] = parsed;
387
+ }
388
+ } catch {
389
+ // 解析失败则保持原值
390
+ }
391
+ }
392
+ }
393
+
394
+ return deserialized as T;
395
+ }
396
+
397
+ static cleanAndSnakeAndSerializeWriteData(data: Record<string, any>, excludeValues: any[] = [null, undefined]): Record<string, any> {
398
+ const cleanData = fieldClear(data, { excludeValues: excludeValues });
399
+ const snakeData = keysToSnake(cleanData);
400
+ return DbUtils.serializeArrayFields(snakeData);
401
+ }
402
+
403
+ static stripSystemFieldsForWrite(data: Record<string, any>, options: { allowState: boolean }): Record<string, any> {
404
+ const result: Record<string, any> = {};
405
+ for (const [key, value] of Object.entries(data)) {
406
+ // 系统字段不可由用户覆盖
407
+ if (key === "id") continue;
408
+ if (key === "created_at") continue;
409
+ if (key === "updated_at") continue;
410
+ if (key === "deleted_at") continue;
411
+ if (!options.allowState && key === "state") continue;
412
+ result[key] = value;
413
+ }
414
+
415
+ return result;
416
+ }
417
+
418
+ static buildInsertRow(options: { data: Record<string, any>; id: number; now: number }): Record<string, any> {
419
+ const serializedData = DbUtils.cleanAndSnakeAndSerializeWriteData(options.data);
420
+ const userData = DbUtils.stripSystemFieldsForWrite(serializedData, { allowState: false });
421
+
422
+ const result: Record<string, any> = {};
423
+ for (const [key, value] of Object.entries(userData)) {
424
+ result[key] = value;
425
+ }
426
+
427
+ result.id = options.id;
428
+ result.created_at = options.now;
429
+ result.updated_at = options.now;
430
+ result.state = 1;
431
+ return result;
432
+ }
433
+
434
+ static buildUpdateRow(options: { data: Record<string, any>; now: number; allowState: boolean }): Record<string, any> {
435
+ const serializedData = DbUtils.cleanAndSnakeAndSerializeWriteData(options.data);
436
+ const userData = DbUtils.stripSystemFieldsForWrite(serializedData, { allowState: options.allowState });
437
+
438
+ const result: Record<string, any> = {};
439
+ for (const [key, value] of Object.entries(userData)) {
440
+ result[key] = value;
441
+ }
442
+ result.updated_at = options.now;
443
+ return result;
444
+ }
445
+
446
+ static buildPartialUpdateData(options: { data: Record<string, any>; allowState: boolean }): Record<string, any> {
447
+ const serializedData = DbUtils.cleanAndSnakeAndSerializeWriteData(options.data);
448
+ return DbUtils.stripSystemFieldsForWrite(serializedData, { allowState: options.allowState });
449
+ }
450
+ }
package/lib/logger.ts CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  import type { LoggerConfig } from "../types/logger.js";
6
6
 
7
+ import { existsSync, mkdirSync } from "node:fs";
7
8
  import { readdir, stat, unlink } from "node:fs/promises";
8
- import { join as nodePathJoin } from "node:path";
9
+ import { isAbsolute as nodePathIsAbsolute, join as nodePathJoin, resolve as nodePathResolve } from "node:path";
9
10
 
10
11
  import { isPlainObject } from "es-toolkit/compat";
11
12
  import { escapeRegExp } from "es-toolkit/string";
@@ -14,6 +15,10 @@ import pino from "pino";
14
15
 
15
16
  import { getCtx } from "./asyncContext.js";
16
17
 
18
+ // 注意:Logger 可能在运行时/测试中被 process.chdir() 影响。
19
+ // 为避免相对路径的 logs 目录随着 cwd 变化,使用模块加载时的初始 cwd 作为锚点。
20
+ const INITIAL_CWD = process.cwd();
21
+
17
22
  const MAX_LOG_STRING_LEN = 100;
18
23
  const MAX_LOG_ARRAY_ITEMS = 100;
19
24
 
@@ -32,6 +37,7 @@ let slowInstance: pino.Logger | null = null;
32
37
  let errorInstance: pino.Logger | null = null;
33
38
  let mockInstance: pino.Logger | null = null;
34
39
  let didPruneOldLogFiles: boolean = false;
40
+ let didEnsureLogDir: boolean = false;
35
41
  let config: LoggerConfig = {
36
42
  debug: 0,
37
43
  dir: "./logs",
@@ -39,11 +45,34 @@ let config: LoggerConfig = {
39
45
  maxSize: 10
40
46
  };
41
47
 
48
+ function resolveLogDir(): string {
49
+ const rawDir = config.dir || "./logs";
50
+ if (nodePathIsAbsolute(rawDir)) {
51
+ return rawDir;
52
+ }
53
+ return nodePathResolve(INITIAL_CWD, rawDir);
54
+ }
55
+
56
+ function ensureLogDirExists(): void {
57
+ if (didEnsureLogDir) return;
58
+ didEnsureLogDir = true;
59
+
60
+ const dir = resolveLogDir();
61
+ try {
62
+ if (!existsSync(dir)) {
63
+ mkdirSync(dir, { recursive: true });
64
+ }
65
+ } catch (error: any) {
66
+ // 不能在 Logger 初始化前调用 Logger 本身,直接抛错即可
67
+ throw new Error(`创建 logs 目录失败: ${dir}. ${error?.message || error}`);
68
+ }
69
+ }
70
+
42
71
  async function pruneOldLogFiles(): Promise<void> {
43
72
  if (didPruneOldLogFiles) return;
44
73
  didPruneOldLogFiles = true;
45
74
 
46
- const dir = config.dir || "./logs";
75
+ const dir = resolveLogDir();
47
76
  const now = Date.now();
48
77
  const cutoff = now - ONE_YEAR_MS;
49
78
 
@@ -90,6 +119,7 @@ export function configure(cfg: LoggerConfig): void {
90
119
  slowInstance = null;
91
120
  errorInstance = null;
92
121
  didPruneOldLogFiles = false;
122
+ didEnsureLogDir = false;
93
123
 
94
124
  // 仅支持数组配置:excludeFields?: string[]
95
125
  const userPatterns = Array.isArray(config.excludeFields) ? config.excludeFields : [];
@@ -172,6 +202,8 @@ export function getLogger(): pino.Logger {
172
202
 
173
203
  if (instance) return instance;
174
204
 
205
+ ensureLogDirExists();
206
+
175
207
  // 启动时清理过期日志(异步,不阻塞初始化)
176
208
  void pruneOldLogFiles();
177
209
 
@@ -183,7 +215,7 @@ export function getLogger(): pino.Logger {
183
215
  target: "pino-roll",
184
216
  level: level,
185
217
  options: {
186
- file: join(config.dir || "./logs", "app"),
218
+ file: join(resolveLogDir(), "app"),
187
219
  frequency: "daily",
188
220
  size: `${config.maxSize || 10}m`,
189
221
  mkdir: true,
@@ -212,6 +244,8 @@ function getSlowLogger(): pino.Logger {
212
244
  if (mockInstance) return mockInstance;
213
245
  if (slowInstance) return slowInstance;
214
246
 
247
+ ensureLogDirExists();
248
+
215
249
  void pruneOldLogFiles();
216
250
 
217
251
  const level = config.debug === 1 ? "debug" : "info";
@@ -223,7 +257,7 @@ function getSlowLogger(): pino.Logger {
223
257
  target: "pino-roll",
224
258
  level: level,
225
259
  options: {
226
- file: join(config.dir || "./logs", "slow"),
260
+ file: join(resolveLogDir(), "slow"),
227
261
  // 只按大小分割(frequency 默认不启用)
228
262
  size: `${config.maxSize || 10}m`,
229
263
  mkdir: true
@@ -240,6 +274,8 @@ function getErrorLogger(): pino.Logger {
240
274
  if (mockInstance) return mockInstance;
241
275
  if (errorInstance) return errorInstance;
242
276
 
277
+ ensureLogDirExists();
278
+
243
279
  void pruneOldLogFiles();
244
280
 
245
281
  // error 专属文件:只关注 error 及以上
@@ -251,7 +287,7 @@ function getErrorLogger(): pino.Logger {
251
287
  target: "pino-roll",
252
288
  level: "error",
253
289
  options: {
254
- file: join(config.dir || "./logs", "error"),
290
+ file: join(resolveLogDir(), "error"),
255
291
  // 只按大小分割(frequency 默认不启用)
256
292
  size: `${config.maxSize || 10}m`,
257
293
  mkdir: true
@@ -10,6 +10,7 @@ import { Logger } from "./logger.js";
10
10
 
11
11
  /**
12
12
  * Redis 助手类
13
+ * 约定:除构造函数外,方法默认不抛异常;失败时返回 null/false/0/[] 并记录日志。
13
14
  */
14
15
  export class RedisHelper {
15
16
  private client: RedisClient;