befly 3.8.18 → 3.8.20

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 (50) hide show
  1. package/README.md +7 -6
  2. package/bunfig.toml +1 -1
  3. package/lib/database.ts +28 -25
  4. package/lib/dbHelper.ts +3 -3
  5. package/lib/jwt.ts +90 -99
  6. package/lib/logger.ts +44 -23
  7. package/lib/redisHelper.ts +19 -22
  8. package/lib/validator.ts +11 -4
  9. package/loader/loadApis.ts +69 -114
  10. package/loader/loadHooks.ts +65 -0
  11. package/loader/loadPlugins.ts +50 -219
  12. package/main.ts +106 -133
  13. package/package.json +15 -7
  14. package/paths.ts +20 -0
  15. package/plugins/cache.ts +1 -3
  16. package/plugins/db.ts +8 -11
  17. package/plugins/logger.ts +5 -3
  18. package/plugins/redis.ts +10 -14
  19. package/router/api.ts +60 -106
  20. package/router/root.ts +15 -12
  21. package/router/static.ts +54 -58
  22. package/sync/syncAll.ts +58 -0
  23. package/sync/syncApi.ts +264 -0
  24. package/sync/syncDb/apply.ts +194 -0
  25. package/sync/syncDb/constants.ts +76 -0
  26. package/sync/syncDb/ddl.ts +194 -0
  27. package/sync/syncDb/helpers.ts +200 -0
  28. package/sync/syncDb/index.ts +164 -0
  29. package/sync/syncDb/schema.ts +201 -0
  30. package/sync/syncDb/sqlite.ts +50 -0
  31. package/sync/syncDb/table.ts +321 -0
  32. package/sync/syncDb/tableCreate.ts +146 -0
  33. package/sync/syncDb/version.ts +72 -0
  34. package/sync/syncDb.ts +19 -0
  35. package/sync/syncDev.ts +206 -0
  36. package/sync/syncMenu.ts +331 -0
  37. package/tsconfig.json +2 -4
  38. package/types/api.d.ts +6 -0
  39. package/types/befly.d.ts +152 -28
  40. package/types/context.d.ts +29 -3
  41. package/types/hook.d.ts +35 -0
  42. package/types/index.ts +14 -1
  43. package/types/plugin.d.ts +6 -7
  44. package/types/sync.d.ts +403 -0
  45. package/check.ts +0 -378
  46. package/env.ts +0 -106
  47. package/lib/middleware.ts +0 -275
  48. package/types/env.ts +0 -65
  49. package/types/util.d.ts +0 -45
  50. package/util.ts +0 -257
@@ -0,0 +1,194 @@
1
+ /**
2
+ * syncDb DDL 构建模块
3
+ *
4
+ * 包含:
5
+ * - 构建索引 SQL
6
+ * - 生成 DDL 子句(添加/修改列)
7
+ * - 安全执行 DDL(MySQL 降级策略)
8
+ * - 构建系统列和业务列定义
9
+ */
10
+
11
+ import { snakeCase } from 'es-toolkit/string';
12
+ import { Logger } from '../../lib/logger.js';
13
+ import { IS_MYSQL, IS_PG, typeMapping } from './constants.js';
14
+ import { quoteIdentifier, resolveDefaultValue, generateDefaultSql, getSqlType, escapeComment } from './helpers.js';
15
+
16
+ import type { SQL } from 'bun';
17
+ import type { FieldDefinition, AnyObject } from 'befly/types/common.js';
18
+
19
+ /**
20
+ * 构建索引操作 SQL(统一使用在线策略)
21
+ *
22
+ * @param tableName - 表名
23
+ * @param indexName - 索引名
24
+ * @param fieldName - 字段名
25
+ * @param action - 操作类型(create/drop)
26
+ * @returns SQL 语句
27
+ */
28
+ export function buildIndexSQL(tableName: string, indexName: string, fieldName: string, action: 'create' | 'drop'): string {
29
+ const tableQuoted = quoteIdentifier(tableName);
30
+ const indexQuoted = quoteIdentifier(indexName);
31
+ const fieldQuoted = quoteIdentifier(fieldName);
32
+
33
+ if (IS_MYSQL) {
34
+ const parts = [];
35
+ if (action === 'create') {
36
+ parts.push(`ADD INDEX ${indexQuoted} (${fieldQuoted})`);
37
+ } else {
38
+ parts.push(`DROP INDEX ${indexQuoted}`);
39
+ }
40
+ // 始终使用在线算法
41
+ parts.push('ALGORITHM=INPLACE');
42
+ parts.push('LOCK=NONE');
43
+ return `ALTER TABLE ${tableQuoted} ${parts.join(', ')}`;
44
+ }
45
+
46
+ if (IS_PG) {
47
+ if (action === 'create') {
48
+ // 始终使用 CONCURRENTLY
49
+ return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
50
+ }
51
+ return `DROP INDEX CONCURRENTLY IF EXISTS ${indexQuoted}`;
52
+ }
53
+
54
+ // SQLite
55
+ if (action === 'create') {
56
+ return `CREATE INDEX IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
57
+ }
58
+ return `DROP INDEX IF EXISTS ${indexQuoted}`;
59
+ }
60
+
61
+ /**
62
+ * 构建系统字段列定义
63
+ *
64
+ * @returns 系统字段的列定义数组
65
+ */
66
+ export function buildSystemColumnDefs(): string[] {
67
+ if (IS_MYSQL) {
68
+ return ['`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键ID"', '`created_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "创建时间"', '`updated_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "更新时间"', '`deleted_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "删除时间"', '`state` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "状态字段"'];
69
+ }
70
+ return ['"id" INTEGER PRIMARY KEY', '"created_at" INTEGER NOT NULL DEFAULT 0', '"updated_at" INTEGER NOT NULL DEFAULT 0', '"deleted_at" INTEGER NOT NULL DEFAULT 0', '"state" INTEGER NOT NULL DEFAULT 0'];
71
+ }
72
+
73
+ /**
74
+ * 构建业务字段列定义
75
+ *
76
+ * @param fields - 字段定义对象
77
+ * @returns 业务字段的列定义数组
78
+ */
79
+ export function buildBusinessColumnDefs(fields: Record<string, FieldDefinition>): string[] {
80
+ const colDefs: string[] = [];
81
+
82
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
83
+ // 转换字段名为下划线格式
84
+ const dbFieldName = snakeCase(fieldKey);
85
+
86
+ const sqlType = getSqlType(fieldDef.type, fieldDef.max, fieldDef.unsigned);
87
+
88
+ // 使用公共函数处理默认值
89
+ const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
90
+ const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
91
+
92
+ // 构建约束
93
+ const uniqueSql = fieldDef.unique ? ' UNIQUE' : '';
94
+ const nullableSql = fieldDef.nullable ? ' NULL' : ' NOT NULL';
95
+
96
+ if (IS_MYSQL) {
97
+ colDefs.push(`\`${dbFieldName}\` ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(fieldDef.name)}"`);
98
+ } else {
99
+ colDefs.push(`"${dbFieldName}" ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`);
100
+ }
101
+ }
102
+
103
+ return colDefs;
104
+ }
105
+
106
+ /**
107
+ * 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
108
+ *
109
+ * @param fieldKey - 字段键名
110
+ * @param fieldDef - 字段定义对象
111
+ * @param isAdd - 是否为添加字段(true)还是修改字段(false)
112
+ * @returns DDL 子句
113
+ */
114
+ export function generateDDLClause(fieldKey: string, fieldDef: FieldDefinition, isAdd: boolean = false): string {
115
+ // 转换字段名为下划线格式
116
+ const dbFieldName = snakeCase(fieldKey);
117
+
118
+ const sqlType = getSqlType(fieldDef.type, fieldDef.max, fieldDef.unsigned);
119
+
120
+ // 使用公共函数处理默认值
121
+ const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
122
+ const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
123
+
124
+ // 构建约束
125
+ const uniqueSql = fieldDef.unique ? ' UNIQUE' : '';
126
+ const nullableSql = fieldDef.nullable ? ' NULL' : ' NOT NULL';
127
+
128
+ if (IS_MYSQL) {
129
+ return `${isAdd ? 'ADD COLUMN' : 'MODIFY COLUMN'} \`${dbFieldName}\` ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(fieldDef.name)}"`;
130
+ }
131
+ if (IS_PG) {
132
+ if (isAdd) return `ADD COLUMN IF NOT EXISTS "${dbFieldName}" ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
133
+ // PG 修改:类型与非空可分条执行,生成 TYPE 改变;非空另由上层统一控制
134
+ return `ALTER COLUMN "${dbFieldName}" TYPE ${sqlType}`;
135
+ }
136
+ // SQLite 仅支持 ADD COLUMN(>=3.50.0:支持 IF NOT EXISTS)
137
+ if (isAdd) return `ADD COLUMN IF NOT EXISTS "${dbFieldName}" ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
138
+ return '';
139
+ }
140
+
141
+ /**
142
+ * 安全执行 DDL 语句(MySQL 降级策略)
143
+ *
144
+ * 执行 DDL 时按以下顺序尝试:
145
+ * 1. ALGORITHM=INSTANT (最快,无表锁)
146
+ * 2. ALGORITHM=INPLACE (在线 DDL)
147
+ * 3. 传统 DDL (可能需要表锁)
148
+ *
149
+ * @param sql - SQL 客户端实例
150
+ * @param stmt - DDL 语句
151
+ * @returns 是否执行成功
152
+ * @throws {Error} 如果所有尝试都失败
153
+ */
154
+ export async function executeDDLSafely(sql: SQL, stmt: string): Promise<boolean> {
155
+ try {
156
+ await sql.unsafe(stmt);
157
+ return true;
158
+ } catch (error: any) {
159
+ // MySQL 专用降级路径
160
+ if (stmt.includes('ALGORITHM=INSTANT')) {
161
+ const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, 'ALGORITHM=INPLACE');
162
+ try {
163
+ await sql.unsafe(inplaceSql);
164
+ return true;
165
+ } catch (inplaceError) {
166
+ // 最后尝试传统DDL:移除 ALGORITHM/LOCK 附加子句后执行
167
+ const traditionSql = stmt
168
+ .replace(/,\s*ALGORITHM=INPLACE/g, '')
169
+ .replace(/,\s*ALGORITHM=INSTANT/g, '')
170
+ .replace(/,\s*LOCK=(NONE|SHARED|EXCLUSIVE)/g, '');
171
+ await sql.unsafe(traditionSql);
172
+ return true;
173
+ }
174
+ } else {
175
+ throw error;
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * PG 兼容类型变更识别:无需数据重写的宽化型变更
182
+ *
183
+ * @param currentType - 当前类型
184
+ * @param newType - 新类型
185
+ * @returns 是否为兼容变更
186
+ */
187
+ export function isPgCompatibleTypeChange(currentType: string, newType: string): boolean {
188
+ const c = String(currentType || '').toLowerCase();
189
+ const n = String(newType || '').toLowerCase();
190
+ // varchar -> text 视为宽化
191
+ if (c === 'character varying' && n === 'text') return true;
192
+ // text -> character varying 非宽化(可能截断),不兼容
193
+ return false;
194
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * syncDb 辅助工具模块
3
+ *
4
+ * 包含:
5
+ * - 标识符引用(反引号/双引号转义)
6
+ * - 默认值处理
7
+ * - 日志输出格式化
8
+ */
9
+
10
+ import { IS_MYSQL, IS_PG, typeMapping } from './constants.js';
11
+ import { Logger } from '../../lib/logger.js';
12
+
13
+ /**
14
+ * 根据数据库类型引用标识符
15
+ *
16
+ * @param identifier - 标识符(表名、列名等)
17
+ * @returns 引用后的标识符
18
+ *
19
+ * @example
20
+ * quoteIdentifier('user_table')
21
+ * // MySQL: `user_table`
22
+ * // PostgreSQL: "user_table"
23
+ * // SQLite: user_table
24
+ */
25
+ export function quoteIdentifier(identifier: string): string {
26
+ if (IS_MYSQL) return `\`${identifier}\``;
27
+ if (IS_PG) return `"${identifier}"`;
28
+ return identifier; // SQLite 无需引用
29
+ }
30
+
31
+ /**
32
+ * 处理默认值:将 null 或 'null' 字符串转换为对应类型的默认值
33
+ *
34
+ * @param fieldDefault - 字段默认值(可能是 null 或 'null' 字符串)
35
+ * @param fieldType - 字段类型(number/string/text/array)
36
+ * @returns 实际默认值
37
+ *
38
+ * @example
39
+ * resolveDefaultValue(null, 'string') // => ''
40
+ * resolveDefaultValue(null, 'number') // => 0
41
+ * resolveDefaultValue('null', 'number') // => 0
42
+ * resolveDefaultValue(null, 'array') // => '[]'
43
+ * resolveDefaultValue(null, 'text') // => 'null'
44
+ * resolveDefaultValue('admin', 'string') // => 'admin'
45
+ * resolveDefaultValue(0, 'number') // => 0
46
+ */
47
+ export function resolveDefaultValue(fieldDefault: any, fieldType: 'number' | 'string' | 'text' | 'array'): any {
48
+ // null 或字符串 'null' 都表示使用类型默认值
49
+ if (fieldDefault !== null && fieldDefault !== 'null') {
50
+ return fieldDefault;
51
+ }
52
+
53
+ // null 表示使用类型默认值
54
+ switch (fieldType) {
55
+ case 'number':
56
+ return 0;
57
+ case 'string':
58
+ return '';
59
+ case 'array':
60
+ return '[]';
61
+ case 'text':
62
+ // text 类型不设置默认值,保持 'null'
63
+ return 'null';
64
+ default:
65
+ return fieldDefault;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 生成 SQL DEFAULT 子句
71
+ *
72
+ * @param actualDefault - 实际默认值(已经过 resolveDefaultValue 处理)
73
+ * @param fieldType - 字段类型
74
+ * @returns SQL DEFAULT 子句字符串(包含前导空格),如果不需要则返回空字符串
75
+ *
76
+ * @example
77
+ * generateDefaultSql(0, 'number') // => ' DEFAULT 0'
78
+ * generateDefaultSql('admin', 'string') // => " DEFAULT 'admin'"
79
+ * generateDefaultSql('', 'string') // => " DEFAULT ''"
80
+ * generateDefaultSql('null', 'text') // => ''
81
+ */
82
+ export function generateDefaultSql(actualDefault: any, fieldType: 'number' | 'string' | 'text' | 'array'): string {
83
+ // text 类型不设置默认值
84
+ if (fieldType === 'text' || actualDefault === 'null') {
85
+ return '';
86
+ }
87
+
88
+ // 仅 number/string/array 类型设置默认值
89
+ if (fieldType === 'number' || fieldType === 'string' || fieldType === 'array') {
90
+ if (typeof actualDefault === 'number' && !Number.isNaN(actualDefault)) {
91
+ return ` DEFAULT ${actualDefault}`;
92
+ } else {
93
+ // 字符串需要转义单引号:' -> ''
94
+ const escaped = String(actualDefault).replace(/'/g, "''");
95
+ return ` DEFAULT '${escaped}'`;
96
+ }
97
+ }
98
+
99
+ return '';
100
+ }
101
+
102
+ /**
103
+ * 判断是否为字符串或数组类型(需要长度参数)
104
+ *
105
+ * @param fieldType - 字段类型
106
+ * @returns 是否为字符串或数组类型
107
+ *
108
+ * @example
109
+ * isStringOrArrayType('string') // => true
110
+ * isStringOrArrayType('array_string') // => true
111
+ * isStringOrArrayType('array_text') // => false
112
+ * isStringOrArrayType('number') // => false
113
+ * isStringOrArrayType('text') // => false
114
+ */
115
+ export function isStringOrArrayType(fieldType: string): boolean {
116
+ return fieldType === 'string' || fieldType === 'array_string';
117
+ }
118
+
119
+ /**
120
+ * 获取 SQL 数据类型
121
+ *
122
+ * @param fieldType - 字段类型(number/string/text/array_string/array_text)
123
+ * @param fieldMax - 最大长度(string/array_string 类型需要)
124
+ * @param unsigned - 是否无符号(仅 MySQL number 类型有效)
125
+ * @returns SQL 类型字符串
126
+ *
127
+ * @example
128
+ * getSqlType('string', 100) // => 'VARCHAR(100)'
129
+ * getSqlType('number', null, true) // => 'BIGINT UNSIGNED'
130
+ * getSqlType('text', null) // => 'MEDIUMTEXT'
131
+ * getSqlType('array_string', 500) // => 'VARCHAR(500)'
132
+ * getSqlType('array_text', null) // => 'MEDIUMTEXT'
133
+ */
134
+ export function getSqlType(fieldType: string, fieldMax: number | null, unsigned: boolean = false): string {
135
+ if (isStringOrArrayType(fieldType)) {
136
+ return `${typeMapping[fieldType]}(${fieldMax})`;
137
+ }
138
+ // 处理 UNSIGNED 修饰符(仅 MySQL number 类型)
139
+ const baseType = typeMapping[fieldType] || 'TEXT';
140
+ if (IS_MYSQL && fieldType === 'number' && unsigned) {
141
+ return `${baseType} UNSIGNED`;
142
+ }
143
+ return baseType;
144
+ }
145
+
146
+ /**
147
+ * 转义 SQL 注释中的双引号
148
+ *
149
+ * @param str - 注释字符串
150
+ * @returns 转义后的字符串
151
+ *
152
+ * @example
153
+ * escapeComment('用户名称') // => '用户名称'
154
+ * escapeComment('用户"昵称"') // => '用户\\"昵称\\"'
155
+ */
156
+ export function escapeComment(str: string): string {
157
+ return String(str).replace(/"/g, '\\"');
158
+ }
159
+
160
+ /**
161
+ * 记录字段变更信息(紧凑格式)
162
+ *
163
+ * @param tableName - 表名
164
+ * @param fieldName - 字段名
165
+ * @param changeType - 变更类型(length/datatype/comment/default)
166
+ * @param oldValue - 旧值
167
+ * @param newValue - 新值
168
+ * @param changeLabel - 变更类型的中文标签
169
+ */
170
+ export function logFieldChange(tableName: string, fieldName: string, changeType: string, oldValue: any, newValue: any, changeLabel: string): void {
171
+ Logger.debug(` ~ 修改 ${fieldName} ${changeLabel}: ${oldValue} -> ${newValue}`);
172
+ }
173
+
174
+ /**
175
+ * 格式化字段列表为可读字符串
176
+ *
177
+ * @param fields - 字段名数组
178
+ * @returns 格式化的字符串(逗号分隔)
179
+ */
180
+ export function formatFieldList(fields: string[]): string {
181
+ return fields.map((f) => quoteIdentifier(f)).join(', ');
182
+ }
183
+
184
+ /**
185
+ * 为字段定义应用默认值
186
+ *
187
+ * @param fieldDef - 字段定义对象
188
+ */
189
+ export function applyFieldDefaults(fieldDef: any): void {
190
+ fieldDef.detail = fieldDef.detail ?? '';
191
+ fieldDef.min = fieldDef.min ?? 0;
192
+ fieldDef.max = fieldDef.max ?? 100;
193
+ fieldDef.default = fieldDef.default ?? null;
194
+ fieldDef.index = fieldDef.index ?? false;
195
+ fieldDef.unique = fieldDef.unique ?? false;
196
+ fieldDef.comment = fieldDef.comment ?? '';
197
+ fieldDef.nullable = fieldDef.nullable ?? false;
198
+ fieldDef.unsigned = fieldDef.unsigned ?? true;
199
+ fieldDef.regexp = fieldDef.regexp ?? null;
200
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * syncDb 主入口文件
3
+ *
4
+ * 功能:
5
+ * - 协调所有模块,执行数据库表结构同步
6
+ * - 处理核心表、项目表、addon 表
7
+ * - 提供统计信息和错误处理
8
+ */
9
+
10
+ import { basename, resolve } from 'pathe';
11
+ import { existsSync } from 'node:fs';
12
+ import { snakeCase } from 'es-toolkit/string';
13
+ import { Database } from '../../lib/database.js';
14
+ import { RedisHelper } from '../../lib/redisHelper.js';
15
+ import { checkTable } from '../../checks/checkTable.js';
16
+ import { scanFiles, scanAddons, addonDirExists, getAddonDir } from 'befly-util';
17
+ import { Logger } from '../../lib/logger.js';
18
+ import { projectDir } from '../../paths.js';
19
+
20
+ // 导入模块化的功能
21
+ import { ensureDbVersion } from './version.js';
22
+ import { tableExists } from './schema.js';
23
+ import { modifyTable } from './table.js';
24
+ import { createTable } from './tableCreate.js';
25
+ import { applyFieldDefaults } from './helpers.js';
26
+ import type { SQL } from 'bun';
27
+ import type { BeflyOptions, SyncDbOptions } from '../../types/index.js';
28
+
29
+ // 全局 SQL 客户端实例
30
+ let sql: SQL | null = null;
31
+
32
+ // 记录处理过的表名(用于清理缓存)
33
+ const processedTables: string[] = [];
34
+
35
+ /**
36
+ * 主同步函数
37
+ *
38
+ * 流程:
39
+ * 1. 验证表定义文件
40
+ * 2. 建立数据库连接并检查版本
41
+ * 3. 扫描表定义文件(核心表、项目表、addon表)
42
+ * 4. 对比并应用表结构变更
43
+ */
44
+ export const SyncDb = async (config: BeflyOptions, options: SyncDbOptions = {}): Promise<void> => {
45
+ try {
46
+ // 清空处理记录
47
+ processedTables.length = 0;
48
+
49
+ // 验证表定义文件
50
+ await checkTable();
51
+
52
+ // 建立数据库连接并检查版本
53
+ sql = await Database.connectSql({ max: 1 });
54
+ await ensureDbVersion(sql);
55
+
56
+ // 初始化 Redis 连接(用于清理缓存)
57
+ await Database.connectRedis();
58
+
59
+ // 扫描表定义文件
60
+ const directories: Array<{ path: string; type: 'app' | 'addon'; addonName?: string; addonNameSnake?: string }> = [];
61
+
62
+ // 1. 项目表(无前缀)- 如果 tables 目录存在
63
+ const projectTablesDir = resolve(projectDir, 'tables');
64
+ if (existsSync(projectTablesDir)) {
65
+ directories.push({ path: projectTablesDir, type: 'app' });
66
+ }
67
+
68
+ // 添加所有 addon 的 tables 目录(addon_{name}_ 前缀)
69
+ const addons = scanAddons();
70
+ for (const addon of addons) {
71
+ if (addonDirExists(addon, 'tables')) {
72
+ directories.push({
73
+ path: getAddonDir(addon, 'tables'),
74
+ type: 'addon',
75
+ addonName: addon,
76
+ addonNameSnake: snakeCase(addon) // 提前转换,避免每个文件都转换
77
+ });
78
+ }
79
+ }
80
+
81
+ // 处理表文件
82
+ for (const dirConfig of directories) {
83
+ const { path: dir, type, addonName } = dirConfig;
84
+ const dirType = type === 'addon' ? `组件${addonName}` : '项目';
85
+
86
+ const files = await scanFiles(dir, '*.json');
87
+
88
+ for (const { filePath: file, fileName } of files) {
89
+ // 确定表名:
90
+ // - addon 表:{addonName}_{表名}
91
+ // 例如:admin addon 的 user.json → admin_user
92
+ // - 项目表:{表名}
93
+ // 例如:user.json → user
94
+ let tableName = snakeCase(fileName);
95
+ if (type === 'addon' && dirConfig.addonNameSnake) {
96
+ // addon 表,使用提前转换好的名称
97
+ tableName = `addon_${dirConfig.addonNameSnake}_${tableName}`;
98
+ }
99
+
100
+ // 如果指定了表名,则只同步该表
101
+ if (options.table && options.table !== tableName) {
102
+ continue;
103
+ }
104
+
105
+ const tableDefinitionModule = await import(file, { with: { type: 'json' } });
106
+ const tableDefinition = tableDefinitionModule.default;
107
+
108
+ // 为字段属性设置默认值
109
+ for (const fieldDef of Object.values(tableDefinition)) {
110
+ applyFieldDefaults(fieldDef);
111
+ }
112
+
113
+ const dbName = config.plugins?.db?.database;
114
+ const existsTable = await tableExists(sql!, tableName, dbName);
115
+
116
+ // 读取 force 参数
117
+ const force = options.force || false;
118
+
119
+ if (existsTable) {
120
+ await modifyTable(sql!, tableName, tableDefinition, force, dbName);
121
+ } else {
122
+ await createTable(sql!, tableName, tableDefinition);
123
+ }
124
+
125
+ // 记录处理过的表名(用于清理缓存)
126
+ processedTables.push(tableName);
127
+ }
128
+ }
129
+
130
+ // 清理 Redis 缓存(如果有表被处理)
131
+ if (processedTables.length > 0) {
132
+ Logger.debug(`🧹 清理 ${processedTables.length} 个表的字段缓存...`);
133
+
134
+ const redisHelper = new RedisHelper();
135
+ for (const tableName of processedTables) {
136
+ const cacheKey = `table:columns:${tableName}`;
137
+ try {
138
+ await redisHelper.del(cacheKey);
139
+ } catch (error: any) {
140
+ Logger.warn(`清理表 ${tableName} 的缓存失败: ${error.message}`);
141
+ }
142
+ }
143
+
144
+ Logger.debug(`✓ 已清理表字段缓存`);
145
+ }
146
+ } catch (error: any) {
147
+ Logger.error(`数据库同步失败`, error);
148
+ throw error;
149
+ } finally {
150
+ if (sql) {
151
+ try {
152
+ await Database.disconnectSql();
153
+ } catch (error: any) {
154
+ Logger.warn(`关闭数据库连接时出错: ${error.message}`);
155
+ }
156
+ }
157
+
158
+ try {
159
+ await Database.disconnectRedis();
160
+ } catch (error: any) {
161
+ Logger.warn(`关闭 Redis 连接时出错: ${error.message}`);
162
+ }
163
+ }
164
+ };