befly 3.9.40 → 3.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -19
- package/befly.config.ts +19 -2
- package/checks/checkApi.ts +79 -77
- package/checks/checkHook.ts +48 -0
- package/checks/checkMenu.ts +168 -0
- package/checks/checkPlugin.ts +48 -0
- package/checks/checkTable.ts +137 -183
- package/docs/README.md +17 -11
- package/docs/api/api.md +16 -2
- package/docs/guide/quickstart.md +31 -10
- package/docs/hooks/hook.md +2 -2
- package/docs/hooks/rateLimit.md +1 -1
- package/docs/infra/redis.md +26 -14
- package/docs/plugins/plugin.md +23 -21
- package/docs/quickstart.md +5 -328
- package/docs/reference/addon.md +0 -4
- package/docs/reference/config.md +14 -31
- package/docs/reference/logger.md +3 -3
- package/docs/reference/sync.md +132 -237
- package/docs/reference/table.md +28 -30
- package/hooks/auth.ts +3 -4
- package/hooks/cors.ts +4 -6
- package/hooks/parser.ts +3 -4
- package/hooks/permission.ts +3 -4
- package/hooks/validator.ts +3 -4
- package/lib/cacheHelper.ts +89 -153
- package/lib/cacheKeys.ts +1 -1
- package/lib/connect.ts +9 -13
- package/lib/dbDialect.ts +285 -0
- package/lib/dbHelper.ts +179 -507
- package/lib/dbUtils.ts +450 -0
- package/lib/logger.ts +41 -5
- package/lib/redisHelper.ts +1 -0
- package/lib/sqlBuilder.ts +358 -58
- package/lib/sqlCheck.ts +136 -0
- package/lib/validator.ts +1 -1
- package/loader/loadApis.ts +23 -126
- package/loader/loadHooks.ts +31 -46
- package/loader/loadPlugins.ts +37 -52
- package/main.ts +58 -19
- package/package.json +24 -25
- package/paths.ts +14 -14
- package/plugins/cache.ts +12 -6
- package/plugins/cipher.ts +2 -2
- package/plugins/config.ts +6 -8
- package/plugins/db.ts +14 -19
- package/plugins/jwt.ts +6 -7
- package/plugins/logger.ts +7 -9
- package/plugins/redis.ts +8 -10
- package/plugins/tool.ts +3 -4
- package/router/api.ts +3 -2
- package/router/static.ts +7 -5
- package/sync/syncApi.ts +80 -235
- package/sync/syncCache.ts +16 -0
- package/sync/syncDev.ts +167 -202
- package/sync/syncMenu.ts +230 -444
- package/sync/syncTable.ts +1247 -0
- package/tests/_mocks/mockSqliteDb.ts +204 -0
- package/tests/addonHelper-cache.test.ts +32 -0
- package/tests/apiHandler-routePath-only.test.ts +32 -0
- package/tests/cacheHelper.test.ts +16 -51
- package/tests/checkApi-routePath-strict.test.ts +166 -0
- package/tests/checkMenu.test.ts +346 -0
- package/tests/checkTable-smoke.test.ts +157 -0
- package/tests/dbDialect-cache.test.ts +23 -0
- package/tests/dbDialect.test.ts +46 -0
- package/tests/dbHelper-advanced.test.ts +1 -1
- package/tests/dbHelper-all-array-types.test.ts +15 -15
- package/tests/dbHelper-batch-write.test.ts +90 -0
- package/tests/dbHelper-columns.test.ts +36 -54
- package/tests/dbHelper-execute.test.ts +26 -26
- package/tests/dbHelper-joins.test.ts +85 -176
- package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
- package/tests/fixtures/scanFilesApis/a.ts +3 -0
- package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
- package/tests/loadPlugins-order-smoke.test.ts +75 -0
- package/tests/logger.test.ts +6 -6
- package/tests/redisHelper.test.ts +6 -1
- package/tests/scanFiles-routePath.test.ts +46 -0
- package/tests/smoke-sql.test.ts +24 -0
- package/tests/sqlBuilder-advanced.test.ts +18 -5
- package/tests/sqlBuilder.test.ts +24 -0
- package/tests/sync-init-guard.test.ts +105 -0
- package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
- package/tests/syncApi-obsolete-records.test.ts +69 -0
- package/tests/syncApi-type-compat.test.ts +72 -0
- package/tests/syncDev-permissions.test.ts +81 -0
- package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
- package/tests/syncMenu-duplicate-path.test.ts +122 -0
- package/tests/syncMenu-obsolete-records.test.ts +161 -0
- package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
- package/tests/syncMenu-paths.test.ts +0 -9
- package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
- package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
- package/tests/syncTable-constants.test.ts +101 -0
- package/tests/syncTable-db-integration.test.ts +237 -0
- package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
- package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
- package/tests/syncTable-schema.test.ts +99 -0
- package/tests/syncTable-testkit.test.ts +25 -0
- package/tests/syncTable-types.test.ts +122 -0
- package/tests/tableRef-and-deserialize.test.ts +67 -0
- package/tsconfig.json +1 -1
- package/types/api.d.ts +1 -1
- package/types/befly.d.ts +13 -12
- package/types/cache.d.ts +2 -2
- package/types/context.d.ts +1 -1
- package/types/database.d.ts +0 -5
- package/types/hook.d.ts +1 -10
- package/types/plugin.d.ts +2 -96
- package/types/sync.d.ts +19 -25
- package/utils/convertBigIntFields.ts +38 -0
- package/utils/disableMenusGlob.ts +85 -0
- package/utils/importDefault.ts +21 -0
- package/utils/isDirentDirectory.ts +23 -0
- package/utils/loadMenuConfigs.ts +145 -0
- package/utils/processFields.ts +25 -0
- package/utils/scanAddons.ts +72 -0
- package/utils/scanFiles.ts +129 -21
- package/utils/scanSources.ts +64 -0
- package/utils/sortModules.ts +137 -0
- package/checks/checkApp.ts +0 -55
- package/docs/cipher.md +0 -582
- package/docs/database.md +0 -1176
- package/hooks/rateLimit.ts +0 -276
- package/sync/syncAll.ts +0 -35
- package/sync/syncDb/apply.ts +0 -192
- package/sync/syncDb/constants.ts +0 -119
- package/sync/syncDb/ddl.ts +0 -251
- package/sync/syncDb/helpers.ts +0 -84
- package/sync/syncDb/schema.ts +0 -202
- package/sync/syncDb/sqlite.ts +0 -48
- package/sync/syncDb/table.ts +0 -207
- package/sync/syncDb/tableCreate.ts +0 -163
- package/sync/syncDb/types.ts +0 -132
- package/sync/syncDb/version.ts +0 -69
- package/sync/syncDb.ts +0 -168
- package/tests/rateLimit-hook.test.ts +0 -477
- package/tests/syncDb-constants.test.ts +0 -130
- package/tests/syncDb-schema.test.ts +0 -179
- package/tests/syncDb-types.test.ts +0 -139
- package/utils/addonHelper.ts +0 -90
- package/utils/modules.ts +0 -98
- 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 =
|
|
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(
|
|
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(
|
|
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(
|
|
290
|
+
file: join(resolveLogDir(), "error"),
|
|
255
291
|
// 只按大小分割(frequency 默认不启用)
|
|
256
292
|
size: `${config.maxSize || 10}m`,
|
|
257
293
|
mkdir: true
|