befly 3.8.25 → 3.8.27

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 (61) 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 +3 -3
  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 +1 -4
  30. package/sync/syncDb/constants.ts +3 -0
  31. package/sync/syncDb/ddl.ts +2 -1
  32. package/sync/syncDb/helpers.ts +5 -117
  33. package/sync/syncDb/sqlite.ts +1 -3
  34. package/sync/syncDb/table.ts +8 -142
  35. package/sync/syncDb/tableCreate.ts +25 -9
  36. package/sync/syncDb/types.ts +125 -0
  37. package/sync/syncDb/version.ts +0 -3
  38. package/sync/syncDb.ts +146 -6
  39. package/sync/syncDev.ts +19 -15
  40. package/sync/syncMenu.ts +87 -75
  41. package/tests/redisHelper.test.ts +15 -16
  42. package/tests/sync-connection.test.ts +189 -0
  43. package/tests/syncDb-apply.test.ts +287 -0
  44. package/tests/syncDb-constants.test.ts +150 -0
  45. package/tests/syncDb-ddl.test.ts +205 -0
  46. package/tests/syncDb-helpers.test.ts +112 -0
  47. package/tests/syncDb-schema.test.ts +178 -0
  48. package/tests/syncDb-types.test.ts +129 -0
  49. package/tsconfig.json +2 -2
  50. package/types/api.d.ts +1 -1
  51. package/types/befly.d.ts +23 -21
  52. package/types/common.d.ts +0 -29
  53. package/types/context.d.ts +8 -6
  54. package/types/hook.d.ts +3 -4
  55. package/types/plugin.d.ts +3 -0
  56. package/hooks/errorHandler.ts +0 -23
  57. package/hooks/requestId.ts +0 -24
  58. package/hooks/requestLogger.ts +0 -25
  59. package/hooks/responseFormatter.ts +0 -64
  60. package/router/root.ts +0 -56
  61. package/sync/syncDb/index.ts +0 -164
package/sync/syncApi.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { readdirSync, statSync } from 'node:fs';
14
14
  import { join, dirname, relative, basename } from 'pathe';
15
- import { Database } from '../lib/database.js';
15
+ import { Connect } from '../lib/connect.js';
16
16
  import { RedisHelper } from '../lib/redisHelper.js';
17
17
  import { scanFiles, scanAddons, addonDirExists, getAddonDir } from 'befly-util';
18
18
 
@@ -106,11 +106,11 @@ async function scanAllApis(projectRoot: string): Promise<ApiInfo[]> {
106
106
  const addonApisDir = getAddonDir(addonName, 'apis');
107
107
 
108
108
  // 读取 addon 配置
109
- const addonConfigPath = getAddonDir(addonName, 'addon.config.json');
109
+ const addonPackageJsonPath = getAddonDir(addonName, 'package.json');
110
110
  let addonTitle = addonName;
111
111
  try {
112
- const config = await import(addonConfigPath, { with: { type: 'json' } });
113
- addonTitle = config.default.title || addonName;
112
+ const packageJson = await import(addonPackageJsonPath, { with: { type: 'json' } });
113
+ addonTitle = packageJson.default?.title || addonName;
114
114
  } catch (error) {
115
115
  // 忽略配置读取错误
116
116
  }
@@ -220,9 +220,9 @@ export async function syncApiCommand(config: BeflyOptions, options: SyncApiOptio
220
220
  }
221
221
 
222
222
  // 连接数据库(SQL + Redis)
223
- await Database.connect();
223
+ await Connect.connect(config);
224
224
 
225
- const helper = Database.getDbHelper();
225
+ const helper = Connect.getDbHelper();
226
226
 
227
227
  // 1. 检查表是否存在(addon_admin_api 来自 addon-admin 组件)
228
228
  const exists = await helper.tableExists('addon_admin_api');
@@ -259,6 +259,6 @@ export async function syncApiCommand(config: BeflyOptions, options: SyncApiOptio
259
259
  Logger.error('API 同步失败:', error);
260
260
  throw error;
261
261
  } finally {
262
- await Database?.disconnect();
262
+ await Connect.disconnect();
263
263
  }
264
264
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { Logger } from '../../lib/logger.js';
10
- import { IS_MYSQL, IS_PG, IS_SQLITE, CHANGE_TYPE_LABELS, typeMapping } from './constants.js';
10
+ import { IS_MYSQL, IS_PG, IS_SQLITE, IS_PLAN, CHANGE_TYPE_LABELS, typeMapping } from './constants.js';
11
11
  import { logFieldChange, resolveDefaultValue, isStringOrArrayType } from './helpers.js';
12
12
  import { executeDDLSafely, buildIndexSQL } from './ddl.js';
13
13
  import { rebuildSqliteTable } from './sqlite.js';
@@ -15,9 +15,6 @@ import type { FieldChange, IndexAction, TablePlan, ColumnInfo } from '../../type
15
15
  import type { SQL } from 'bun';
16
16
  import type { FieldDefinition } from 'befly/types/common';
17
17
 
18
- // 是否为计划模式(从环境变量读取)
19
- const IS_PLAN = process.argv.includes('--plan');
20
-
21
18
  /**
22
19
  * 构建 ALTER TABLE SQL 语句
23
20
  *
@@ -60,6 +60,9 @@ export const MYSQL_TABLE_CONFIG = {
60
60
  COLLATE: 'utf8mb4_0900_ai_ci'
61
61
  } as const;
62
62
 
63
+ // 是否为计划模式(仅输出 SQL 不执行)
64
+ export const IS_PLAN = process.argv.includes('--plan');
65
+
63
66
  // 数据库类型判断
64
67
  export const DB = (process.env.DB_TYPE || 'mysql').toLowerCase();
65
68
  export const IS_MYSQL = DB === 'mysql';
@@ -11,7 +11,8 @@
11
11
  import { snakeCase } from 'es-toolkit/string';
12
12
  import { Logger } from '../../lib/logger.js';
13
13
  import { IS_MYSQL, IS_PG, typeMapping } from './constants.js';
14
- import { quoteIdentifier, resolveDefaultValue, generateDefaultSql, getSqlType, escapeComment } from './helpers.js';
14
+ import { quoteIdentifier, escapeComment } from './helpers.js';
15
+ import { resolveDefaultValue, generateDefaultSql, getSqlType } from './types.js';
15
16
 
16
17
  import type { SQL } from 'bun';
17
18
  import type { FieldDefinition, AnyObject } from 'befly/types/common.js';
@@ -3,13 +3,16 @@
3
3
  *
4
4
  * 包含:
5
5
  * - 标识符引用(反引号/双引号转义)
6
- * - 默认值处理
7
6
  * - 日志输出格式化
7
+ * - 字段默认值应用
8
8
  */
9
9
 
10
- import { IS_MYSQL, IS_PG, typeMapping } from './constants.js';
10
+ import { IS_MYSQL, IS_PG } from './constants.js';
11
11
  import { Logger } from '../../lib/logger.js';
12
12
 
13
+ // 从 types.ts 重新导出,保持向后兼容
14
+ export { isStringOrArrayType, getSqlType, resolveDefaultValue, generateDefaultSql } from './types.js';
15
+
13
16
  /**
14
17
  * 根据数据库类型引用标识符
15
18
  *
@@ -28,121 +31,6 @@ export function quoteIdentifier(identifier: string): string {
28
31
  return identifier; // SQLite 无需引用
29
32
  }
30
33
 
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
34
  /**
147
35
  * 转义 SQL 注释中的双引号
148
36
  *
@@ -6,12 +6,10 @@
6
6
  */
7
7
 
8
8
  import { Logger } from '../../lib/logger.js';
9
+ import { IS_PLAN } from './constants.js';
9
10
  import { createTable } from './tableCreate.js';
10
11
  import type { SQL } from 'bun';
11
12
 
12
- // 是否为计划模式(从环境变量读取)
13
- const IS_PLAN = process.argv.includes('--plan');
14
-
15
13
  /**
16
14
  * SQLite 重建表迁移(简化版)
17
15
  *
@@ -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 { IS_MYSQL, IS_PG, CHANGE_TYPE_LABELS, typeMapping } 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 = [];
@@ -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 { IS_MYSQL, IS_PG, 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 (IS_MYSQL) {
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 (IS_MYSQL && 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 (IS_MYSQL && 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
 
@@ -142,5 +158,5 @@ export async function createTable(sql: SQL, tableName: string, fields: Record<st
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,125 @@
1
+ /**
2
+ * syncDb 类型处理模块
3
+ *
4
+ * 包含:
5
+ * - SQL 类型映射和转换
6
+ * - 默认值处理
7
+ * - 类型判断工具
8
+ */
9
+
10
+ import { IS_MYSQL, typeMapping } 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
+ if (isStringOrArrayType(fieldType)) {
46
+ return `${typeMapping[fieldType]}(${fieldMax})`;
47
+ }
48
+ // 处理 UNSIGNED 修饰符(仅 MySQL number 类型)
49
+ const baseType = typeMapping[fieldType] || 'TEXT';
50
+ if (IS_MYSQL && fieldType === 'number' && unsigned) {
51
+ return `${baseType} UNSIGNED`;
52
+ }
53
+ return baseType;
54
+ }
55
+
56
+ /**
57
+ * 处理默认值:将 null 或 'null' 字符串转换为对应类型的默认值
58
+ *
59
+ * @param fieldDefault - 字段默认值(可能是 null 或 'null' 字符串)
60
+ * @param fieldType - 字段类型(number/string/text/array)
61
+ * @returns 实际默认值
62
+ *
63
+ * @example
64
+ * resolveDefaultValue(null, 'string') // => ''
65
+ * resolveDefaultValue(null, 'number') // => 0
66
+ * resolveDefaultValue('null', 'number') // => 0
67
+ * resolveDefaultValue(null, 'array') // => '[]'
68
+ * resolveDefaultValue(null, 'text') // => 'null'
69
+ * resolveDefaultValue('admin', 'string') // => 'admin'
70
+ * resolveDefaultValue(0, 'number') // => 0
71
+ */
72
+ export function resolveDefaultValue(fieldDefault: any, fieldType: 'number' | 'string' | 'text' | 'array'): any {
73
+ // null 或字符串 'null' 都表示使用类型默认值
74
+ if (fieldDefault !== null && fieldDefault !== 'null') {
75
+ return fieldDefault;
76
+ }
77
+
78
+ // null 表示使用类型默认值
79
+ switch (fieldType) {
80
+ case 'number':
81
+ return 0;
82
+ case 'string':
83
+ return '';
84
+ case 'array':
85
+ return '[]';
86
+ case 'text':
87
+ // text 类型不设置默认值,保持 'null'
88
+ return 'null';
89
+ default:
90
+ return fieldDefault;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 生成 SQL DEFAULT 子句
96
+ *
97
+ * @param actualDefault - 实际默认值(已经过 resolveDefaultValue 处理)
98
+ * @param fieldType - 字段类型
99
+ * @returns SQL DEFAULT 子句字符串(包含前导空格),如果不需要则返回空字符串
100
+ *
101
+ * @example
102
+ * generateDefaultSql(0, 'number') // => ' DEFAULT 0'
103
+ * generateDefaultSql('admin', 'string') // => " DEFAULT 'admin'"
104
+ * generateDefaultSql('', 'string') // => " DEFAULT ''"
105
+ * generateDefaultSql('null', 'text') // => ''
106
+ */
107
+ export function generateDefaultSql(actualDefault: any, fieldType: 'number' | 'string' | 'text' | 'array'): string {
108
+ // text 类型不设置默认值
109
+ if (fieldType === 'text' || actualDefault === 'null') {
110
+ return '';
111
+ }
112
+
113
+ // 仅 number/string/array 类型设置默认值
114
+ if (fieldType === 'number' || fieldType === 'string' || fieldType === 'array') {
115
+ if (typeof actualDefault === 'number' && !Number.isNaN(actualDefault)) {
116
+ return ` DEFAULT ${actualDefault}`;
117
+ } else {
118
+ // 字符串需要转义单引号:' -> ''
119
+ const escaped = String(actualDefault).replace(/'/g, "''");
120
+ return ` DEFAULT '${escaped}'`;
121
+ }
122
+ }
123
+
124
+ return '';
125
+ }
@@ -33,7 +33,6 @@ 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
 
@@ -43,7 +42,6 @@ export async function ensureDbVersion(sql: SQL): Promise<void> {
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) {
@@ -58,7 +56,6 @@ export async function ensureDbVersion(sql: SQL): Promise<void> {
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('.')