befly 3.8.25 → 3.8.29

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 (62) hide show
  1. package/config.ts +8 -9
  2. package/hooks/{rateLimit.ts → _rateLimit.ts} +7 -13
  3. package/hooks/auth.ts +3 -11
  4. package/hooks/cors.ts +1 -4
  5. package/hooks/parser.ts +6 -8
  6. package/hooks/permission.ts +9 -12
  7. package/hooks/validator.ts +6 -9
  8. package/lib/cacheHelper.ts +0 -4
  9. package/lib/{database.ts → connect.ts} +65 -18
  10. package/lib/logger.ts +1 -17
  11. package/lib/redisHelper.ts +6 -5
  12. package/loader/loadApis.ts +3 -3
  13. package/loader/loadHooks.ts +15 -41
  14. package/loader/loadPlugins.ts +10 -16
  15. package/main.ts +25 -28
  16. package/package.json +4 -4
  17. package/plugins/cache.ts +2 -2
  18. package/plugins/cipher.ts +15 -0
  19. package/plugins/config.ts +16 -0
  20. package/plugins/db.ts +7 -17
  21. package/plugins/jwt.ts +15 -0
  22. package/plugins/logger.ts +1 -1
  23. package/plugins/redis.ts +4 -4
  24. package/plugins/tool.ts +50 -0
  25. package/router/api.ts +56 -42
  26. package/router/static.ts +12 -12
  27. package/sync/syncAll.ts +2 -20
  28. package/sync/syncApi.ts +7 -7
  29. package/sync/syncDb/apply.ts +10 -12
  30. package/sync/syncDb/constants.ts +64 -12
  31. package/sync/syncDb/ddl.ts +9 -8
  32. package/sync/syncDb/helpers.ts +7 -119
  33. package/sync/syncDb/schema.ts +16 -19
  34. package/sync/syncDb/sqlite.ts +1 -3
  35. package/sync/syncDb/table.ts +13 -146
  36. package/sync/syncDb/tableCreate.ts +28 -12
  37. package/sync/syncDb/types.ts +126 -0
  38. package/sync/syncDb/version.ts +4 -7
  39. package/sync/syncDb.ts +151 -6
  40. package/sync/syncDev.ts +19 -15
  41. package/sync/syncMenu.ts +87 -75
  42. package/tests/redisHelper.test.ts +15 -16
  43. package/tests/sync-connection.test.ts +189 -0
  44. package/tests/syncDb-apply.test.ts +288 -0
  45. package/tests/syncDb-constants.test.ts +151 -0
  46. package/tests/syncDb-ddl.test.ts +206 -0
  47. package/tests/syncDb-helpers.test.ts +113 -0
  48. package/tests/syncDb-schema.test.ts +178 -0
  49. package/tests/syncDb-types.test.ts +130 -0
  50. package/tsconfig.json +2 -2
  51. package/types/api.d.ts +1 -1
  52. package/types/befly.d.ts +23 -21
  53. package/types/common.d.ts +0 -29
  54. package/types/context.d.ts +8 -6
  55. package/types/hook.d.ts +3 -4
  56. package/types/plugin.d.ts +3 -0
  57. package/hooks/errorHandler.ts +0 -23
  58. package/hooks/requestId.ts +0 -24
  59. package/hooks/requestLogger.ts +0 -25
  60. package/hooks/responseFormatter.ts +0 -64
  61. package/router/root.ts +0 -56
  62. package/sync/syncDb/index.ts +0 -164
@@ -9,19 +9,15 @@
9
9
 
10
10
  import { snakeCase } from 'es-toolkit/string';
11
11
  import { Logger } from '../../lib/logger.js';
12
- import { IS_MYSQL, IS_PG, IS_SQLITE, SYSTEM_INDEX_FIELDS, CHANGE_TYPE_LABELS, typeMapping } from './constants.js';
13
- import { quoteIdentifier, logFieldChange, resolveDefaultValue, generateDefaultSql, isStringOrArrayType, getSqlType } from './helpers.js';
14
- import { buildIndexSQL, generateDDLClause, isPgCompatibleTypeChange } from './ddl.js';
12
+ import { isMySQL, isPG, CHANGE_TYPE_LABELS, getTypeMapping } from './constants.js';
13
+ import { logFieldChange, resolveDefaultValue, generateDefaultSql, isStringOrArrayType } from './helpers.js';
14
+ import { generateDDLClause, isPgCompatibleTypeChange } from './ddl.js';
15
15
  import { getTableColumns, getTableIndexes } from './schema.js';
16
16
  import { compareFieldDefinition, applyTablePlan } from './apply.js';
17
- import { createTable } from './tableCreate.js';
18
- import type { TablePlan, ColumnInfo } from '../../types.js';
17
+ import type { TablePlan } from '../../types.js';
19
18
  import type { SQL } from 'bun';
20
19
  import type { FieldDefinition } from 'befly/types/common';
21
20
 
22
- // 是否为计划模式(从环境变量读取)
23
- const IS_PLAN = process.argv.includes('--plan');
24
-
25
21
  /**
26
22
  * 同步表结构(对比和应用变更)
27
23
  *
@@ -33,143 +29,13 @@ const IS_PLAN = process.argv.includes('--plan');
33
29
  *
34
30
  * @param sql - SQL 客户端实例
35
31
  * @param tableName - 表名
36
- * @param tableDefinition - 表定义(JSON)
32
+ * @param fields - 字段定义
37
33
  * @param force - 是否强制同步(删除多余字段)
38
34
  * @param dbName - 数据库名称
39
35
  */
40
- export async function modifyTable(sql: SQL, tableName: string, tableDefinition: TablePlan, force: boolean, dbName?: string): Promise<void> {
41
- try {
42
- // 1. 获取现有表结构
43
- const currentColumns = await getTableColumns(sql, tableName, dbName);
44
- const currentIndexes = await getTableIndexes(sql, tableName, dbName);
45
-
46
- // 2. 对比字段变化
47
- const changes: string[] = [];
48
- const processedColumns = new Set<string>();
49
- const processedIndexes = new Set<string>();
50
- const newColumns: string[] = []; // 记录新增的列名,用于后续添加索引
51
-
52
- // 遍历定义中的字段
53
- for (const [fieldName, fieldDef] of Object.entries(tableDefinition)) {
54
- const snakeFieldName = snakeCase(fieldName);
55
- processedColumns.add(snakeFieldName);
56
-
57
- // 检查字段是否存在
58
- if (!currentColumns[snakeFieldName]) {
59
- // 新增字段
60
- changes.push(generateDDLClause('ADD_COLUMN', tableName, snakeFieldName, fieldDef));
61
- logFieldChange(tableName, snakeFieldName, 'add', '新增字段');
62
- newColumns.push(snakeFieldName);
63
- } else {
64
- // 修改字段
65
- const currentDef = currentColumns[snakeFieldName];
66
- const diff = compareFieldDefinition(fieldDef, currentDef);
67
- if (diff) {
68
- changes.push(generateDDLClause('MODIFY_COLUMN', tableName, snakeFieldName, fieldDef));
69
- logFieldChange(tableName, snakeFieldName, 'modify', `修改字段: ${diff}`);
70
- }
71
- }
72
- }
73
-
74
- // 检查多余字段(仅在 force 模式下删除)
75
- if (force) {
76
- for (const colName of Object.keys(currentColumns)) {
77
- if (!processedColumns.has(colName)) {
78
- changes.push(generateDDLClause('DROP_COLUMN', tableName, colName));
79
- logFieldChange(tableName, colName, 'drop', '删除多余字段');
80
- }
81
- }
82
- }
83
-
84
- // 3. 对比索引变化
85
- // 自动为 _id, _at 结尾的字段添加索引
86
- // 以及 unique=true 的字段
87
- const expectedIndexes: { [key: string]: string[] } = {};
88
-
89
- for (const [fieldName, fieldDef] of Object.entries(tableDefinition)) {
90
- const snakeFieldName = snakeCase(fieldName);
91
-
92
- // 唯一索引
93
- if (fieldDef.unique) {
94
- const indexName = `uk_${tableName}_${snakeFieldName}`;
95
- expectedIndexes[indexName] = [snakeFieldName];
96
- }
97
- // 普通索引 (index=true 或 _id/_at 结尾)
98
- else if (fieldDef.index || snakeFieldName.endsWith('_id') || snakeFieldName.endsWith('_at')) {
99
- // 排除主键 id
100
- if (snakeFieldName === 'id') continue;
101
-
102
- // 排除大文本类型
103
- if (['text', 'longtext', 'json'].includes(fieldDef.type || '')) continue;
104
-
105
- const indexName = `idx_${tableName}_${snakeFieldName}`;
106
- expectedIndexes[indexName] = [snakeFieldName];
107
- }
108
- }
109
-
110
- // 检查新增/修改索引
111
- for (const [indexName, columns] of Object.entries(expectedIndexes)) {
112
- processedIndexes.add(indexName);
113
-
114
- const currentIndex = currentIndexes[indexName];
115
- if (!currentIndex) {
116
- // 新增索引
117
- changes.push(buildIndexSQL('ADD', tableName, indexName, columns, indexName.startsWith('uk_')));
118
- logFieldChange(tableName, indexName, 'add_index', `新增索引 (${columns.join(',')})`);
119
- } else {
120
- // 索引存在,检查是否一致
121
- const isSame = currentIndex.length === columns.length && currentIndex.every((col, i) => col === columns[i]);
122
-
123
- if (!isSame) {
124
- // 修改索引(先删后加)
125
- changes.push(buildIndexSQL('DROP', tableName, indexName));
126
- changes.push(buildIndexSQL('ADD', tableName, indexName, columns, indexName.startsWith('uk_')));
127
- logFieldChange(tableName, indexName, 'modify_index', `修改索引 (${columns.join(',')})`);
128
- }
129
- }
130
- }
131
-
132
- // 检查多余索引(仅在 force 模式下删除)
133
- if (force) {
134
- for (const indexName of Object.keys(currentIndexes)) {
135
- // 跳过系统索引
136
- if (SYSTEM_INDEX_FIELDS.includes(indexName)) continue;
137
-
138
- if (!processedIndexes.has(indexName)) {
139
- changes.push(buildIndexSQL('DROP', tableName, indexName));
140
- logFieldChange(tableName, indexName, 'drop_index', '删除多余索引');
141
- }
142
- }
143
- }
144
-
145
- // 4. 执行变更
146
- if (changes.length > 0) {
147
- if (IS_PLAN) {
148
- // 计划模式:只输出 SQL
149
- Logger.info(`[PLAN] 表 ${tableName} 变更 SQL:`);
150
- for (const sqlStr of changes) {
151
- console.log(sqlStr + ';');
152
- }
153
- } else {
154
- // 执行模式
155
- Logger.info(`正在同步表 ${tableName} 结构...`);
156
- for (const sqlStr of changes) {
157
- try {
158
- await sql.unsafe(sqlStr);
159
- } catch (error: any) {
160
- Logger.warn(`执行 SQL 失败: ${sqlStr} - ${error.message}`);
161
- // 继续执行后续变更
162
- }
163
- }
164
- }
165
- }
166
- } catch (error: any) {
167
- throw new Error(`同步表结构失败 [${tableName}]: ${error.message}`);
168
- }
169
- }
170
- export async function modifyTable(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, force: boolean = false): Promise<TablePlan> {
171
- const existingColumns = await getTableColumns(sql, tableName);
172
- const existingIndexes = await getTableIndexes(sql, tableName);
36
+ export async function modifyTable(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, force: boolean = false, dbName?: string): Promise<TablePlan> {
37
+ const existingColumns = await getTableColumns(sql, tableName, dbName);
38
+ const existingIndexes = await getTableIndexes(sql, tableName, dbName);
173
39
  let changed = false;
174
40
 
175
41
  const addClauses = [];
@@ -226,9 +92,9 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
226
92
  }
227
93
 
228
94
  if (v !== null && v !== '') {
229
- if (IS_PG) {
95
+ if (isPG()) {
230
96
  defaultClauses.push(`ALTER COLUMN "${dbFieldName}" SET DEFAULT ${v}`);
231
- } else if (IS_MYSQL && onlyDefaultChanged) {
97
+ } else if (isMySQL() && onlyDefaultChanged) {
232
98
  // MySQL 的 TEXT/BLOB 不允许 DEFAULT,跳过 text 类型
233
99
  if (fieldDef.type !== 'text') {
234
100
  defaultClauses.push(`ALTER COLUMN \`${dbFieldName}\` SET DEFAULT ${v}`);
@@ -246,7 +112,8 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
246
112
  }
247
113
 
248
114
  if (hasTypeChange) {
249
- if (IS_PG && isPgCompatibleTypeChange(existingColumns[dbFieldName].type, typeMapping[fieldDef.type].toLowerCase())) {
115
+ const typeMapping = getTypeMapping();
116
+ if (isPG() && isPgCompatibleTypeChange(existingColumns[dbFieldName].type, typeMapping[fieldDef.type].toLowerCase())) {
250
117
  Logger.debug(`[PG兼容类型变更] ${tableName}.${dbFieldName} ${existingColumns[dbFieldName].type} -> ${typeMapping[fieldDef.type].toLowerCase()} 允许执行`);
251
118
  }
252
119
  }
@@ -289,7 +156,7 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
289
156
 
290
157
  // PG 列注释处理
291
158
  const commentActions = [];
292
- if (IS_PG) {
159
+ if (isPG()) {
293
160
  for (const [fieldKey, fieldDef] of Object.entries(fields)) {
294
161
  // 转换字段名为下划线格式
295
162
  const dbFieldName = snakeCase(fieldKey);
@@ -10,16 +10,14 @@
10
10
  */
11
11
  import { snakeCase } from 'es-toolkit/string';
12
12
  import { Logger } from '../../lib/logger.js';
13
- import { IS_MYSQL, IS_PG, MYSQL_TABLE_CONFIG } from './constants.js';
13
+ import { isMySQL, isPG, IS_PLAN, MYSQL_TABLE_CONFIG } from './constants.js';
14
14
  import { quoteIdentifier } from './helpers.js';
15
15
  import { buildSystemColumnDefs, buildBusinessColumnDefs, buildIndexSQL } from './ddl.js';
16
+ import { getTableIndexes } from './schema.js';
16
17
 
17
18
  import type { SQL } from 'bun';
18
19
  import type { FieldDefinition } from 'befly/types/common';
19
20
 
20
- // 是否为计划模式(从环境变量读取)
21
- const IS_PLAN = process.argv.includes('--plan');
22
-
23
21
  /**
24
22
  * 为 PostgreSQL 表添加列注释
25
23
  *
@@ -68,13 +66,25 @@ async function addPostgresComments(sql: SQL, tableName: string, fields: Record<s
68
66
  * @param tableName - 表名
69
67
  * @param fields - 字段定义对象
70
68
  * @param systemIndexFields - 系统字段索引列表
69
+ * @param dbName - 数据库名称(用于检查索引是否存在)
71
70
  */
72
- async function createTableIndexes(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, systemIndexFields: string[]): Promise<void> {
71
+ async function createTableIndexes(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, systemIndexFields: string[], dbName?: string): Promise<void> {
73
72
  const indexTasks: Promise<void>[] = [];
74
73
 
74
+ // 获取现有索引(MySQL 不支持 IF NOT EXISTS,需要先检查)
75
+ let existingIndexes: Record<string, string[]> = {};
76
+ if (isMySQL()) {
77
+ existingIndexes = await getTableIndexes(sql, tableName, dbName);
78
+ }
79
+
75
80
  // 系统字段索引
76
81
  for (const sysField of systemIndexFields) {
77
- const stmt = buildIndexSQL(tableName, `idx_${sysField}`, sysField, 'create');
82
+ const indexName = `idx_${sysField}`;
83
+ // MySQL 跳过已存在的索引
84
+ if (isMySQL() && existingIndexes[indexName]) {
85
+ continue;
86
+ }
87
+ const stmt = buildIndexSQL(tableName, indexName, sysField, 'create');
78
88
  if (IS_PLAN) {
79
89
  Logger.debug(`[计划] ${stmt}`);
80
90
  } else {
@@ -88,7 +98,12 @@ async function createTableIndexes(sql: SQL, tableName: string, fields: Record<st
88
98
  const dbFieldName = snakeCase(fieldKey);
89
99
 
90
100
  if (fieldDef.index === true) {
91
- const stmt = buildIndexSQL(tableName, `idx_${dbFieldName}`, dbFieldName, 'create');
101
+ const indexName = `idx_${dbFieldName}`;
102
+ // MySQL 跳过已存在的索引
103
+ if (isMySQL() && existingIndexes[indexName]) {
104
+ continue;
105
+ }
106
+ const stmt = buildIndexSQL(tableName, indexName, dbFieldName, 'create');
92
107
  if (IS_PLAN) {
93
108
  Logger.debug(`[计划] ${stmt}`);
94
109
  } else {
@@ -110,8 +125,9 @@ async function createTableIndexes(sql: SQL, tableName: string, fields: Record<st
110
125
  * @param tableName - 表名
111
126
  * @param fields - 字段定义对象
112
127
  * @param systemIndexFields - 系统字段索引列表(可选,默认使用 ['created_at', 'updated_at', 'state'])
128
+ * @param dbName - 数据库名称(用于检查索引是否存在)
113
129
  */
114
- export async function createTable(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, systemIndexFields: string[] = ['created_at', 'updated_at', 'state']): Promise<void> {
130
+ export async function createTable(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, systemIndexFields: string[] = ['created_at', 'updated_at', 'state'], dbName?: string): Promise<void> {
115
131
  // 构建列定义
116
132
  const colDefs = [...buildSystemColumnDefs(), ...buildBusinessColumnDefs(fields)];
117
133
 
@@ -119,7 +135,7 @@ export async function createTable(sql: SQL, tableName: string, fields: Record<st
119
135
  const cols = colDefs.join(',\n ');
120
136
  const tableQuoted = quoteIdentifier(tableName);
121
137
  const { ENGINE, CHARSET, COLLATE } = MYSQL_TABLE_CONFIG;
122
- const createSQL = IS_MYSQL
138
+ const createSQL = isMySQL()
123
139
  ? `CREATE TABLE ${tableQuoted} (
124
140
  ${cols}
125
141
  ) ENGINE=${ENGINE} DEFAULT CHARSET=${CHARSET} COLLATE=${COLLATE}`
@@ -134,13 +150,13 @@ export async function createTable(sql: SQL, tableName: string, fields: Record<st
134
150
  }
135
151
 
136
152
  // PostgreSQL: 添加列注释
137
- if (IS_PG && !IS_PLAN) {
153
+ if (isPG() && !IS_PLAN) {
138
154
  await addPostgresComments(sql, tableName, fields);
139
- } else if (IS_PG && IS_PLAN) {
155
+ } else if (isPG() && IS_PLAN) {
140
156
  // 计划模式也要输出注释语句
141
157
  await addPostgresComments(sql, tableName, fields);
142
158
  }
143
159
 
144
160
  // 创建索引
145
- await createTableIndexes(sql, tableName, fields, systemIndexFields);
161
+ await createTableIndexes(sql, tableName, fields, systemIndexFields, dbName);
146
162
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * syncDb 类型处理模块
3
+ *
4
+ * 包含:
5
+ * - SQL 类型映射和转换
6
+ * - 默认值处理
7
+ * - 类型判断工具
8
+ */
9
+
10
+ import { isMySQL, getTypeMapping } from './constants.js';
11
+
12
+ /**
13
+ * 判断是否为字符串或数组类型(需要长度参数)
14
+ *
15
+ * @param fieldType - 字段类型
16
+ * @returns 是否为字符串或数组类型
17
+ *
18
+ * @example
19
+ * isStringOrArrayType('string') // => true
20
+ * isStringOrArrayType('array_string') // => true
21
+ * isStringOrArrayType('array_text') // => false
22
+ * isStringOrArrayType('number') // => false
23
+ * isStringOrArrayType('text') // => false
24
+ */
25
+ export function isStringOrArrayType(fieldType: string): boolean {
26
+ return fieldType === 'string' || fieldType === 'array_string';
27
+ }
28
+
29
+ /**
30
+ * 获取 SQL 数据类型
31
+ *
32
+ * @param fieldType - 字段类型(number/string/text/array_string/array_text)
33
+ * @param fieldMax - 最大长度(string/array_string 类型需要)
34
+ * @param unsigned - 是否无符号(仅 MySQL number 类型有效)
35
+ * @returns SQL 类型字符串
36
+ *
37
+ * @example
38
+ * getSqlType('string', 100) // => 'VARCHAR(100)'
39
+ * getSqlType('number', null, true) // => 'BIGINT UNSIGNED'
40
+ * getSqlType('text', null) // => 'MEDIUMTEXT'
41
+ * getSqlType('array_string', 500) // => 'VARCHAR(500)'
42
+ * getSqlType('array_text', null) // => 'MEDIUMTEXT'
43
+ */
44
+ export function getSqlType(fieldType: string, fieldMax: number | null, unsigned: boolean = false): string {
45
+ const typeMapping = getTypeMapping();
46
+ if (isStringOrArrayType(fieldType)) {
47
+ return `${typeMapping[fieldType]}(${fieldMax})`;
48
+ }
49
+ // 处理 UNSIGNED 修饰符(仅 MySQL number 类型)
50
+ const baseType = typeMapping[fieldType] || 'TEXT';
51
+ if (isMySQL() && fieldType === 'number' && unsigned) {
52
+ return `${baseType} UNSIGNED`;
53
+ }
54
+ return baseType;
55
+ }
56
+
57
+ /**
58
+ * 处理默认值:将 null 或 'null' 字符串转换为对应类型的默认值
59
+ *
60
+ * @param fieldDefault - 字段默认值(可能是 null 或 'null' 字符串)
61
+ * @param fieldType - 字段类型(number/string/text/array)
62
+ * @returns 实际默认值
63
+ *
64
+ * @example
65
+ * resolveDefaultValue(null, 'string') // => ''
66
+ * resolveDefaultValue(null, 'number') // => 0
67
+ * resolveDefaultValue('null', 'number') // => 0
68
+ * resolveDefaultValue(null, 'array') // => '[]'
69
+ * resolveDefaultValue(null, 'text') // => 'null'
70
+ * resolveDefaultValue('admin', 'string') // => 'admin'
71
+ * resolveDefaultValue(0, 'number') // => 0
72
+ */
73
+ export function resolveDefaultValue(fieldDefault: any, fieldType: 'number' | 'string' | 'text' | 'array'): any {
74
+ // null 或字符串 'null' 都表示使用类型默认值
75
+ if (fieldDefault !== null && fieldDefault !== 'null') {
76
+ return fieldDefault;
77
+ }
78
+
79
+ // null 表示使用类型默认值
80
+ switch (fieldType) {
81
+ case 'number':
82
+ return 0;
83
+ case 'string':
84
+ return '';
85
+ case 'array':
86
+ return '[]';
87
+ case 'text':
88
+ // text 类型不设置默认值,保持 'null'
89
+ return 'null';
90
+ default:
91
+ return fieldDefault;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 生成 SQL DEFAULT 子句
97
+ *
98
+ * @param actualDefault - 实际默认值(已经过 resolveDefaultValue 处理)
99
+ * @param fieldType - 字段类型
100
+ * @returns SQL DEFAULT 子句字符串(包含前导空格),如果不需要则返回空字符串
101
+ *
102
+ * @example
103
+ * generateDefaultSql(0, 'number') // => ' DEFAULT 0'
104
+ * generateDefaultSql('admin', 'string') // => " DEFAULT 'admin'"
105
+ * generateDefaultSql('', 'string') // => " DEFAULT ''"
106
+ * generateDefaultSql('null', 'text') // => ''
107
+ */
108
+ export function generateDefaultSql(actualDefault: any, fieldType: 'number' | 'string' | 'text' | 'array'): string {
109
+ // text 类型不设置默认值
110
+ if (fieldType === 'text' || actualDefault === 'null') {
111
+ return '';
112
+ }
113
+
114
+ // 仅 number/string/array 类型设置默认值
115
+ if (fieldType === 'number' || fieldType === 'string' || fieldType === 'array') {
116
+ if (typeof actualDefault === 'number' && !Number.isNaN(actualDefault)) {
117
+ return ` DEFAULT ${actualDefault}`;
118
+ } else {
119
+ // 字符串需要转义单引号:' -> ''
120
+ const escaped = String(actualDefault).replace(/'/g, "''");
121
+ return ` DEFAULT '${escaped}'`;
122
+ }
123
+ }
124
+
125
+ return '';
126
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { Logger } from '../../lib/logger.js';
9
- import { DB_VERSION_REQUIREMENTS, IS_MYSQL, IS_PG, IS_SQLITE } from './constants.js';
9
+ import { DB_VERSION_REQUIREMENTS, isMySQL, isPG, isSQLite } from './constants.js';
10
10
  import type { SQL } from 'bun';
11
11
 
12
12
  /**
@@ -23,7 +23,7 @@ import type { SQL } from 'bun';
23
23
  export async function ensureDbVersion(sql: SQL): Promise<void> {
24
24
  if (!sql) throw new Error('SQL 客户端未初始化');
25
25
 
26
- if (IS_MYSQL) {
26
+ if (isMySQL()) {
27
27
  const r = await sql`SELECT VERSION() AS version`;
28
28
  if (!r || r.length === 0 || !r[0]?.version) {
29
29
  throw new Error('无法获取 MySQL 版本信息');
@@ -33,17 +33,15 @@ export async function ensureDbVersion(sql: SQL): Promise<void> {
33
33
  if (!Number.isFinite(majorVersion) || majorVersion < DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR) {
34
34
  throw new Error(`此脚本仅支持 MySQL ${DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR}.0+,当前版本: ${version}`);
35
35
  }
36
- Logger.debug(`MySQL 版本: ${version}`);
37
36
  return;
38
37
  }
39
38
 
40
- if (IS_PG) {
39
+ if (isPG()) {
41
40
  const r = await sql`SELECT version() AS version`;
42
41
  if (!r || r.length === 0 || !r[0]?.version) {
43
42
  throw new Error('无法获取 PostgreSQL 版本信息');
44
43
  }
45
44
  const versionText = r[0].version;
46
- Logger.debug(`PostgreSQL 版本: ${versionText}`);
47
45
  const m = /PostgreSQL\s+(\d+)/i.exec(versionText);
48
46
  const major = m ? parseInt(m[1], 10) : NaN;
49
47
  if (!Number.isFinite(major) || major < DB_VERSION_REQUIREMENTS.POSTGRES_MIN_MAJOR) {
@@ -52,13 +50,12 @@ export async function ensureDbVersion(sql: SQL): Promise<void> {
52
50
  return;
53
51
  }
54
52
 
55
- if (IS_SQLITE) {
53
+ if (isSQLite()) {
56
54
  const r = await sql`SELECT sqlite_version() AS version`;
57
55
  if (!r || r.length === 0 || !r[0]?.version) {
58
56
  throw new Error('无法获取 SQLite 版本信息');
59
57
  }
60
58
  const version = r[0].version;
61
- Logger.debug(`SQLite 版本: ${version}`);
62
59
  // 强制最低版本:SQLite ≥ 3.50.0
63
60
  const [maj, min, patch] = String(version)
64
61
  .split('.')
package/sync/syncDb.ts CHANGED
@@ -1,19 +1,164 @@
1
1
  /**
2
2
  * SyncDb 命令 - 同步数据库表结构
3
+ *
4
+ * 功能:
5
+ * - 协调所有模块,执行数据库表结构同步
6
+ * - 处理核心表、项目表、addon 表
7
+ * - 提供统计信息和错误处理
3
8
  */
4
9
 
5
- import { join } from 'pathe';
10
+ import { resolve } from 'pathe';
6
11
  import { existsSync } from 'node:fs';
12
+ import { snakeCase } from 'es-toolkit/string';
13
+ import { Connect } from '../lib/connect.js';
14
+ import { RedisHelper } from '../lib/redisHelper.js';
15
+ import { checkTable } from '../checks/checkTable.js';
16
+ import { scanFiles, scanAddons, addonDirExists, getAddonDir } from 'befly-util';
7
17
  import { Logger } from '../lib/logger.js';
8
- import { SyncDb } from './syncDb/index.js';
9
- import type { SyncDbOptions, BeflyOptions } from '../types/index.js';
18
+ import { projectDir } from '../paths.js';
10
19
 
11
- export async function syncDbCommand(config: BeflyOptions, options: SyncDbOptions): Promise<void> {
20
+ // 导入模块化的功能
21
+ import { ensureDbVersion } from './syncDb/version.js';
22
+ import { tableExists } from './syncDb/schema.js';
23
+ import { modifyTable } from './syncDb/table.js';
24
+ import { createTable } from './syncDb/tableCreate.js';
25
+ import { applyFieldDefaults } from './syncDb/helpers.js';
26
+ import { setDbType } from './syncDb/constants.js';
27
+ import type { SQL } from 'bun';
28
+ import type { BeflyOptions, SyncDbOptions } from '../types/index.js';
29
+
30
+ // 全局 SQL 客户端实例
31
+ let sql: SQL | null = null;
32
+
33
+ // 记录处理过的表名(用于清理缓存)
34
+ const processedTables: string[] = [];
35
+
36
+ /**
37
+ * syncDbCommand - 数据库同步命令入口
38
+ *
39
+ * 流程:
40
+ * 1. 验证表定义文件
41
+ * 2. 建立数据库连接并检查版本
42
+ * 3. 扫描表定义文件(核心表、项目表、addon表)
43
+ * 4. 对比并应用表结构变更
44
+ */
45
+ export async function syncDbCommand(config: BeflyOptions, options: SyncDbOptions = {}): Promise<void> {
12
46
  try {
13
- // 执行同步
14
- await SyncDb(config, options);
47
+ // 清空处理记录
48
+ processedTables.length = 0;
49
+
50
+ // 设置数据库类型(从配置获取)
51
+ const dbType = config.db?.type || 'mysql';
52
+ setDbType(dbType);
53
+
54
+ // 验证表定义文件
55
+ await checkTable();
56
+
57
+ // 建立数据库连接并检查版本
58
+ sql = await Connect.connectSql({ max: 1 });
59
+ await ensureDbVersion(sql);
60
+
61
+ // 初始化 Redis 连接(用于清理缓存)
62
+ await Connect.connectRedis();
63
+
64
+ // 扫描表定义文件
65
+ const directories: Array<{ path: string; type: 'app' | 'addon'; addonName?: string; addonNameSnake?: string }> = [];
66
+
67
+ // 1. 项目表(无前缀)- 如果 tables 目录存在
68
+ const projectTablesDir = resolve(projectDir, 'tables');
69
+ if (existsSync(projectTablesDir)) {
70
+ directories.push({ path: projectTablesDir, type: 'app' });
71
+ }
72
+
73
+ // 添加所有 addon 的 tables 目录(addon_{name}_ 前缀)
74
+ const addons = scanAddons();
75
+ for (const addon of addons) {
76
+ if (addonDirExists(addon, 'tables')) {
77
+ directories.push({
78
+ path: getAddonDir(addon, 'tables'),
79
+ type: 'addon',
80
+ addonName: addon,
81
+ addonNameSnake: snakeCase(addon) // 提前转换,避免每个文件都转换
82
+ });
83
+ }
84
+ }
85
+
86
+ // 处理表文件
87
+ for (const dirConfig of directories) {
88
+ const { path: dir, type } = dirConfig;
89
+
90
+ const files = await scanFiles(dir, '*.json');
91
+
92
+ for (const { filePath: file, fileName } of files) {
93
+ // 确定表名:
94
+ // - addon 表:{addonName}_{表名}
95
+ // 例如:admin addon 的 user.json → admin_user
96
+ // - 项目表:{表名}
97
+ // 例如:user.json → user
98
+ let tableName = snakeCase(fileName);
99
+ if (type === 'addon' && dirConfig.addonNameSnake) {
100
+ // addon 表,使用提前转换好的名称
101
+ tableName = `addon_${dirConfig.addonNameSnake}_${tableName}`;
102
+ }
103
+
104
+ // 如果指定了表名,则只同步该表
105
+ if (options.table && options.table !== tableName) {
106
+ continue;
107
+ }
108
+
109
+ const tableDefinitionModule = await import(file, { with: { type: 'json' } });
110
+ const tableDefinition = tableDefinitionModule.default;
111
+
112
+ // 为字段属性设置默认值
113
+ for (const fieldDef of Object.values(tableDefinition)) {
114
+ applyFieldDefaults(fieldDef);
115
+ }
116
+
117
+ const dbName = config.db?.database;
118
+ const existsTable = await tableExists(sql!, tableName, dbName);
119
+
120
+ // 读取 force 参数
121
+ const force = options.force || false;
122
+
123
+ if (existsTable) {
124
+ await modifyTable(sql!, tableName, tableDefinition, force, dbName);
125
+ } else {
126
+ await createTable(sql!, tableName, tableDefinition, ['created_at', 'updated_at', 'state'], dbName);
127
+ }
128
+
129
+ // 记录处理过的表名(用于清理缓存)
130
+ processedTables.push(tableName);
131
+ }
132
+ }
133
+
134
+ // 清理 Redis 缓存(如果有表被处理)
135
+ if (processedTables.length > 0) {
136
+ const redisHelper = new RedisHelper();
137
+ for (const tableName of processedTables) {
138
+ const cacheKey = `table:columns:${tableName}`;
139
+ try {
140
+ await redisHelper.del(cacheKey);
141
+ } catch (error: any) {
142
+ Logger.warn(`清理表 ${tableName} 的缓存失败: ${error.message}`);
143
+ }
144
+ }
145
+ }
15
146
  } catch (error: any) {
16
147
  Logger.error('数据库同步失败', error);
17
148
  throw error;
149
+ } finally {
150
+ if (sql) {
151
+ try {
152
+ await Connect.disconnectSql();
153
+ } catch (error: any) {
154
+ Logger.warn(`关闭数据库连接时出错: ${error.message}`);
155
+ }
156
+ }
157
+
158
+ try {
159
+ await Connect.disconnectRedis();
160
+ } catch (error: any) {
161
+ Logger.warn(`关闭 Redis 连接时出错: ${error.message}`);
162
+ }
18
163
  }
19
164
  }