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.
- package/config.ts +8 -9
- package/hooks/{rateLimit.ts → _rateLimit.ts} +7 -13
- package/hooks/auth.ts +3 -11
- package/hooks/cors.ts +1 -4
- package/hooks/parser.ts +6 -8
- package/hooks/permission.ts +9 -12
- package/hooks/validator.ts +6 -9
- package/lib/cacheHelper.ts +0 -4
- package/lib/{database.ts → connect.ts} +65 -18
- package/lib/logger.ts +1 -17
- package/lib/redisHelper.ts +6 -5
- package/loader/loadApis.ts +3 -3
- package/loader/loadHooks.ts +15 -41
- package/loader/loadPlugins.ts +10 -16
- package/main.ts +25 -28
- package/package.json +4 -4
- package/plugins/cache.ts +2 -2
- package/plugins/cipher.ts +15 -0
- package/plugins/config.ts +16 -0
- package/plugins/db.ts +7 -17
- package/plugins/jwt.ts +15 -0
- package/plugins/logger.ts +1 -1
- package/plugins/redis.ts +4 -4
- package/plugins/tool.ts +50 -0
- package/router/api.ts +56 -42
- package/router/static.ts +12 -12
- package/sync/syncAll.ts +2 -20
- package/sync/syncApi.ts +7 -7
- package/sync/syncDb/apply.ts +10 -12
- package/sync/syncDb/constants.ts +64 -12
- package/sync/syncDb/ddl.ts +9 -8
- package/sync/syncDb/helpers.ts +7 -119
- package/sync/syncDb/schema.ts +16 -19
- package/sync/syncDb/sqlite.ts +1 -3
- package/sync/syncDb/table.ts +13 -146
- package/sync/syncDb/tableCreate.ts +28 -12
- package/sync/syncDb/types.ts +126 -0
- package/sync/syncDb/version.ts +4 -7
- package/sync/syncDb.ts +151 -6
- package/sync/syncDev.ts +19 -15
- package/sync/syncMenu.ts +87 -75
- package/tests/redisHelper.test.ts +15 -16
- package/tests/sync-connection.test.ts +189 -0
- package/tests/syncDb-apply.test.ts +288 -0
- package/tests/syncDb-constants.test.ts +151 -0
- package/tests/syncDb-ddl.test.ts +206 -0
- package/tests/syncDb-helpers.test.ts +113 -0
- package/tests/syncDb-schema.test.ts +178 -0
- package/tests/syncDb-types.test.ts +130 -0
- package/tsconfig.json +2 -2
- package/types/api.d.ts +1 -1
- package/types/befly.d.ts +23 -21
- package/types/common.d.ts +0 -29
- package/types/context.d.ts +8 -6
- package/types/hook.d.ts +3 -4
- package/types/plugin.d.ts +3 -0
- package/hooks/errorHandler.ts +0 -23
- package/hooks/requestId.ts +0 -24
- package/hooks/requestLogger.ts +0 -25
- package/hooks/responseFormatter.ts +0 -64
- package/router/root.ts +0 -56
- package/sync/syncDb/index.ts +0 -164
package/sync/syncDb/table.ts
CHANGED
|
@@ -9,19 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
import { snakeCase } from 'es-toolkit/string';
|
|
11
11
|
import { Logger } from '../../lib/logger.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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 {
|
|
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
|
|
32
|
+
* @param fields - 字段定义
|
|
37
33
|
* @param force - 是否强制同步(删除多余字段)
|
|
38
34
|
* @param dbName - 数据库名称
|
|
39
35
|
*/
|
|
40
|
-
export async function modifyTable(sql: SQL, tableName: string,
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
95
|
+
if (isPG()) {
|
|
230
96
|
defaultClauses.push(`ALTER COLUMN "${dbFieldName}" SET DEFAULT ${v}`);
|
|
231
|
-
} else if (
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|
|
153
|
+
if (isPG() && !IS_PLAN) {
|
|
138
154
|
await addPostgresComments(sql, tableName, fields);
|
|
139
|
-
} else if (
|
|
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
|
+
}
|
package/sync/syncDb/version.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Logger } from '../../lib/logger.js';
|
|
9
|
-
import { DB_VERSION_REQUIREMENTS,
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 {
|
|
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 {
|
|
9
|
-
import type { SyncDbOptions, BeflyOptions } from '../types/index.js';
|
|
18
|
+
import { projectDir } from '../paths.js';
|
|
10
19
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
}
|