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.
- package/README.md +7 -6
- package/bunfig.toml +1 -1
- package/lib/database.ts +28 -25
- package/lib/dbHelper.ts +3 -3
- package/lib/jwt.ts +90 -99
- package/lib/logger.ts +44 -23
- package/lib/redisHelper.ts +19 -22
- package/lib/validator.ts +11 -4
- package/loader/loadApis.ts +69 -114
- package/loader/loadHooks.ts +65 -0
- package/loader/loadPlugins.ts +50 -219
- package/main.ts +106 -133
- package/package.json +15 -7
- package/paths.ts +20 -0
- package/plugins/cache.ts +1 -3
- package/plugins/db.ts +8 -11
- package/plugins/logger.ts +5 -3
- package/plugins/redis.ts +10 -14
- package/router/api.ts +60 -106
- package/router/root.ts +15 -12
- package/router/static.ts +54 -58
- package/sync/syncAll.ts +58 -0
- package/sync/syncApi.ts +264 -0
- package/sync/syncDb/apply.ts +194 -0
- package/sync/syncDb/constants.ts +76 -0
- package/sync/syncDb/ddl.ts +194 -0
- package/sync/syncDb/helpers.ts +200 -0
- package/sync/syncDb/index.ts +164 -0
- package/sync/syncDb/schema.ts +201 -0
- package/sync/syncDb/sqlite.ts +50 -0
- package/sync/syncDb/table.ts +321 -0
- package/sync/syncDb/tableCreate.ts +146 -0
- package/sync/syncDb/version.ts +72 -0
- package/sync/syncDb.ts +19 -0
- package/sync/syncDev.ts +206 -0
- package/sync/syncMenu.ts +331 -0
- package/tsconfig.json +2 -4
- package/types/api.d.ts +6 -0
- package/types/befly.d.ts +152 -28
- package/types/context.d.ts +29 -3
- package/types/hook.d.ts +35 -0
- package/types/index.ts +14 -1
- package/types/plugin.d.ts +6 -7
- package/types/sync.d.ts +403 -0
- package/check.ts +0 -378
- package/env.ts +0 -106
- package/lib/middleware.ts +0 -275
- package/types/env.ts +0 -65
- package/types/util.d.ts +0 -45
- package/util.ts +0 -257
package/sync/syncApi.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncApi 命令 - 同步 API 接口数据到数据库
|
|
3
|
+
* 说明:遍历所有 API 文件,收集接口路由信息并同步到 addon_admin_api 表
|
|
4
|
+
*
|
|
5
|
+
* 流程:
|
|
6
|
+
* 1. 扫描项目 apis 目录下所有项目 API 文件
|
|
7
|
+
* 2. 扫描 node_modules/@befly-addon/* 目录下所有组件 API 文件
|
|
8
|
+
* 3. 提取每个 API 的 name、method、auth 等信息
|
|
9
|
+
* 4. 根据接口路径检查是否存在
|
|
10
|
+
* 5. 存在则更新,不存在则新增
|
|
11
|
+
* 6. 删除配置中不存在的接口记录
|
|
12
|
+
*/
|
|
13
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, dirname, relative, basename } from 'pathe';
|
|
15
|
+
import { Database } from '../lib/database.js';
|
|
16
|
+
import { RedisHelper } from '../lib/redisHelper.js';
|
|
17
|
+
import { scanFiles, scanAddons, addonDirExists, getAddonDir } from 'befly-util';
|
|
18
|
+
|
|
19
|
+
import { Logger } from '../lib/logger.js';
|
|
20
|
+
import { projectDir } from '../paths.js';
|
|
21
|
+
|
|
22
|
+
import type { SyncApiOptions, ApiInfo, BeflyOptions } from '../types/index.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 从 API 文件中提取接口信息
|
|
26
|
+
*/
|
|
27
|
+
async function extractApiInfo(filePath: string, apiRoot: string, type: 'app' | 'addon', addonName: string = '', addonTitle: string = ''): Promise<ApiInfo | null> {
|
|
28
|
+
try {
|
|
29
|
+
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
|
30
|
+
const apiModule = await import(normalizedFilePath);
|
|
31
|
+
const apiConfig = apiModule.default;
|
|
32
|
+
|
|
33
|
+
if (!apiConfig || !apiConfig.name) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let apiPath = '';
|
|
38
|
+
|
|
39
|
+
if (type === 'addon') {
|
|
40
|
+
// Addon 接口:保留完整目录层级
|
|
41
|
+
// 例: apis/menu/list.ts → /api/addon/admin/menu/list
|
|
42
|
+
const relativePath = relative(apiRoot, filePath);
|
|
43
|
+
const pathWithoutExt = relativePath.replace(/\.(ts|js)$/, '');
|
|
44
|
+
apiPath = `/api/addon/${addonName}/${pathWithoutExt}`;
|
|
45
|
+
} else {
|
|
46
|
+
// 项目接口:保留完整目录层级
|
|
47
|
+
// 例: apis/user/list.ts → /api/user/list
|
|
48
|
+
const relativePath = relative(apiRoot, filePath);
|
|
49
|
+
const pathWithoutExt = relativePath.replace(/\.(ts|js)$/, '');
|
|
50
|
+
apiPath = `/api/${pathWithoutExt}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: apiConfig.name || '',
|
|
55
|
+
path: apiPath,
|
|
56
|
+
method: apiConfig.method || 'POST',
|
|
57
|
+
description: apiConfig.description || '',
|
|
58
|
+
addonName: addonName,
|
|
59
|
+
addonTitle: addonTitle || addonName
|
|
60
|
+
};
|
|
61
|
+
} catch (error: any) {
|
|
62
|
+
Logger.error('同步 API 失败:', error);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 扫描所有 API 文件
|
|
69
|
+
*/
|
|
70
|
+
async function scanAllApis(projectRoot: string): Promise<ApiInfo[]> {
|
|
71
|
+
const apis: ApiInfo[] = [];
|
|
72
|
+
|
|
73
|
+
// 1. 扫描项目 API(只扫描 apis 目录)
|
|
74
|
+
try {
|
|
75
|
+
const projectApisDir = join(projectDir, 'apis');
|
|
76
|
+
|
|
77
|
+
// 扫描项目 API 文件
|
|
78
|
+
const projectApiFiles: string[] = [];
|
|
79
|
+
try {
|
|
80
|
+
const files = await scanFiles(projectApisDir);
|
|
81
|
+
for (const { filePath } of files) {
|
|
82
|
+
projectApiFiles.push(filePath);
|
|
83
|
+
}
|
|
84
|
+
} catch (error: any) {
|
|
85
|
+
Logger.warn(`扫描项目 API 目录失败: ${projectApisDir} - ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const filePath of projectApiFiles) {
|
|
89
|
+
const apiInfo = await extractApiInfo(filePath, projectApisDir, 'app', '', '项目接口');
|
|
90
|
+
if (apiInfo) {
|
|
91
|
+
apis.push(apiInfo);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. 扫描组件 API (node_modules/@befly-addon/*)
|
|
96
|
+
const addonNames = scanAddons();
|
|
97
|
+
|
|
98
|
+
for (const addonName of addonNames) {
|
|
99
|
+
// addonName 格式: admin, demo 等
|
|
100
|
+
|
|
101
|
+
// 检查 apis 子目录是否存在
|
|
102
|
+
if (!addonDirExists(addonName, 'apis')) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const addonApisDir = getAddonDir(addonName, 'apis');
|
|
107
|
+
|
|
108
|
+
// 读取 addon 配置
|
|
109
|
+
const addonConfigPath = getAddonDir(addonName, 'addon.config.json');
|
|
110
|
+
let addonTitle = addonName;
|
|
111
|
+
try {
|
|
112
|
+
const config = await import(addonConfigPath, { with: { type: 'json' } });
|
|
113
|
+
addonTitle = config.default.title || addonName;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// 忽略配置读取错误
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 扫描 addon API 文件
|
|
119
|
+
const addonApiFiles: string[] = [];
|
|
120
|
+
try {
|
|
121
|
+
const files = await scanFiles(addonApisDir);
|
|
122
|
+
for (const { filePath } of files) {
|
|
123
|
+
addonApiFiles.push(filePath);
|
|
124
|
+
}
|
|
125
|
+
} catch (error: any) {
|
|
126
|
+
Logger.warn(`扫描 addon API 目录失败: ${addonApisDir} - ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const filePath of addonApiFiles) {
|
|
130
|
+
const apiInfo = await extractApiInfo(filePath, addonApisDir, 'addon', addonName, addonTitle);
|
|
131
|
+
if (apiInfo) {
|
|
132
|
+
apis.push(apiInfo);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return apis;
|
|
138
|
+
} catch (error: any) {
|
|
139
|
+
Logger.error(`接口扫描失败:`, error);
|
|
140
|
+
return apis;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 同步 API 数据到数据库
|
|
146
|
+
*/
|
|
147
|
+
async function syncApis(helper: any, apis: ApiInfo[]): Promise<void> {
|
|
148
|
+
for (const api of apis) {
|
|
149
|
+
try {
|
|
150
|
+
// 根据 path 查询是否存在
|
|
151
|
+
const existing = await helper.getOne({
|
|
152
|
+
table: 'addon_admin_api',
|
|
153
|
+
where: { path: api.path }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (existing) {
|
|
157
|
+
// 检查是否需要更新
|
|
158
|
+
const needUpdate = existing.name !== api.name || existing.method !== api.method || existing.description !== api.description || existing.addonName !== api.addonName || existing.addonTitle !== api.addonTitle;
|
|
159
|
+
|
|
160
|
+
if (needUpdate) {
|
|
161
|
+
await helper.updData({
|
|
162
|
+
table: 'addon_admin_api',
|
|
163
|
+
where: { id: existing.id },
|
|
164
|
+
data: {
|
|
165
|
+
name: api.name,
|
|
166
|
+
method: api.method,
|
|
167
|
+
description: api.description,
|
|
168
|
+
addonName: api.addonName,
|
|
169
|
+
addonTitle: api.addonTitle
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
await helper.insData({
|
|
175
|
+
table: 'addon_admin_api',
|
|
176
|
+
data: {
|
|
177
|
+
name: api.name,
|
|
178
|
+
path: api.path,
|
|
179
|
+
method: api.method,
|
|
180
|
+
description: api.description,
|
|
181
|
+
addonName: api.addonName,
|
|
182
|
+
addonTitle: api.addonTitle
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
} catch (error: any) {
|
|
187
|
+
Logger.error(`同步接口 "${api.name}" 失败:`, error);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 删除配置中不存在的记录
|
|
194
|
+
*/
|
|
195
|
+
async function deleteObsoleteRecords(helper: any, apiPaths: Set<string>): Promise<void> {
|
|
196
|
+
const allRecords = await helper.getAll({
|
|
197
|
+
table: 'addon_admin_api',
|
|
198
|
+
fields: ['id', 'path'],
|
|
199
|
+
where: { state$gte: 0 }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
for (const record of allRecords) {
|
|
203
|
+
if (record.path && !apiPaths.has(record.path)) {
|
|
204
|
+
await helper.delForce({
|
|
205
|
+
table: 'addon_admin_api',
|
|
206
|
+
where: { id: record.id }
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* SyncApi 命令主函数
|
|
214
|
+
*/
|
|
215
|
+
export async function syncApiCommand(config: BeflyOptions, options: SyncApiOptions = {}): Promise<void> {
|
|
216
|
+
try {
|
|
217
|
+
if (options.plan) {
|
|
218
|
+
Logger.debug('[计划] 同步 API 接口到数据库(plan 模式不执行)');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 连接数据库(SQL + Redis)
|
|
223
|
+
await Database.connect();
|
|
224
|
+
|
|
225
|
+
const helper = Database.getDbHelper();
|
|
226
|
+
|
|
227
|
+
// 1. 检查表是否存在(addon_admin_api 来自 addon-admin 组件)
|
|
228
|
+
const exists = await helper.tableExists('addon_admin_api');
|
|
229
|
+
|
|
230
|
+
if (!exists) {
|
|
231
|
+
Logger.debug('表 addon_admin_api 不存在,跳过 API 同步(需要安装 addon-admin 组件)');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 2. 扫描所有 API 文件
|
|
236
|
+
const apis = await scanAllApis(projectDir);
|
|
237
|
+
const apiPaths = new Set(apis.map((api) => api.path));
|
|
238
|
+
|
|
239
|
+
// 3. 同步 API 数据
|
|
240
|
+
await syncApis(helper, apis);
|
|
241
|
+
|
|
242
|
+
// 4. 删除文件中不存在的接口
|
|
243
|
+
await deleteObsoleteRecords(helper, apiPaths);
|
|
244
|
+
|
|
245
|
+
// 5. 缓存接口数据到 Redis
|
|
246
|
+
try {
|
|
247
|
+
const apiList = await helper.getAll({
|
|
248
|
+
table: 'addon_admin_api',
|
|
249
|
+
fields: ['id', 'name', 'path', 'method', 'description', 'addonName', 'addonTitle'],
|
|
250
|
+
orderBy: ['addonName#ASC', 'path#ASC']
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const redisHelper = new RedisHelper();
|
|
254
|
+
await redisHelper.setObject('apis:all', apiList);
|
|
255
|
+
} catch (error: any) {
|
|
256
|
+
// 忽略缓存错误
|
|
257
|
+
}
|
|
258
|
+
} catch (error: any) {
|
|
259
|
+
Logger.error('API 同步失败:', error);
|
|
260
|
+
throw error;
|
|
261
|
+
} finally {
|
|
262
|
+
await Database?.disconnect();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 变更应用模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - 比较字段定义变化
|
|
6
|
+
* - 应用表结构变更计划
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Logger } from '../../lib/logger.js';
|
|
10
|
+
import { IS_MYSQL, IS_PG, IS_SQLITE, CHANGE_TYPE_LABELS, typeMapping } from './constants.js';
|
|
11
|
+
import { logFieldChange, resolveDefaultValue, isStringOrArrayType } from './helpers.js';
|
|
12
|
+
import { executeDDLSafely, buildIndexSQL } from './ddl.js';
|
|
13
|
+
import { rebuildSqliteTable } from './sqlite.js';
|
|
14
|
+
import type { FieldChange, IndexAction, TablePlan, ColumnInfo } from '../../types.js';
|
|
15
|
+
import type { SQL } from 'bun';
|
|
16
|
+
import type { FieldDefinition } from 'befly/types/common';
|
|
17
|
+
|
|
18
|
+
// 是否为计划模式(从环境变量读取)
|
|
19
|
+
const IS_PLAN = process.argv.includes('--plan');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 构建 ALTER TABLE SQL 语句
|
|
23
|
+
*
|
|
24
|
+
* 根据数据库类型构建相应的 ALTER TABLE 语句:
|
|
25
|
+
* - MySQL: 添加 ALGORITHM=INSTANT, LOCK=NONE 优化参数
|
|
26
|
+
* - PostgreSQL/SQLite: 使用双引号标识符
|
|
27
|
+
*
|
|
28
|
+
* @param tableName - 表名
|
|
29
|
+
* @param clauses - SQL 子句数组
|
|
30
|
+
* @returns 完整的 ALTER TABLE 语句
|
|
31
|
+
*/
|
|
32
|
+
function buildAlterTableSQL(tableName: string, clauses: string[]): string {
|
|
33
|
+
if (IS_MYSQL) {
|
|
34
|
+
return `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
35
|
+
}
|
|
36
|
+
return `ALTER TABLE "${tableName}" ${clauses.join(', ')}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 比较字段定义变化
|
|
41
|
+
*
|
|
42
|
+
* 对比现有列信息和新的字段规则,识别变化类型:
|
|
43
|
+
* - 长度变化(string/array 类型)
|
|
44
|
+
* - 注释变化(MySQL/PG)
|
|
45
|
+
* - 数据类型变化
|
|
46
|
+
* - 默认值变化
|
|
47
|
+
*
|
|
48
|
+
* @param existingColumn - 现有列信息
|
|
49
|
+
* @param fieldDef - 新的字段定义对象
|
|
50
|
+
* @param colName - 列名(未使用,保留参数兼容性)
|
|
51
|
+
* @returns 变化数组
|
|
52
|
+
*/
|
|
53
|
+
export function compareFieldDefinition(existingColumn: ColumnInfo, fieldDef: FieldDefinition): FieldChange[] {
|
|
54
|
+
const changes: FieldChange[] = [];
|
|
55
|
+
|
|
56
|
+
// 检查长度变化(string和array类型) - SQLite 不比较长度
|
|
57
|
+
if (!IS_SQLITE && isStringOrArrayType(fieldDef.type)) {
|
|
58
|
+
if (existingColumn.max !== fieldDef.max) {
|
|
59
|
+
changes.push({
|
|
60
|
+
type: 'length',
|
|
61
|
+
current: existingColumn.max,
|
|
62
|
+
expected: fieldDef.max
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 检查注释变化(MySQL/PG 支持列注释)
|
|
68
|
+
if (!IS_SQLITE) {
|
|
69
|
+
const currentComment = existingColumn.comment || '';
|
|
70
|
+
if (currentComment !== fieldDef.name) {
|
|
71
|
+
changes.push({
|
|
72
|
+
type: 'comment',
|
|
73
|
+
current: currentComment,
|
|
74
|
+
expected: fieldDef.name
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 检查数据类型变化(只对比基础类型)
|
|
80
|
+
const expectedType = typeMapping[fieldDef.type].toLowerCase();
|
|
81
|
+
const currentType = existingColumn.type.toLowerCase();
|
|
82
|
+
|
|
83
|
+
if (currentType !== expectedType) {
|
|
84
|
+
changes.push({
|
|
85
|
+
type: 'datatype',
|
|
86
|
+
current: currentType,
|
|
87
|
+
expected: expectedType
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 检查 nullable 变化
|
|
92
|
+
const expectedNullable = fieldDef.nullable;
|
|
93
|
+
if (existingColumn.nullable !== expectedNullable) {
|
|
94
|
+
changes.push({
|
|
95
|
+
type: 'nullable',
|
|
96
|
+
current: existingColumn.nullable,
|
|
97
|
+
expected: expectedNullable
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 使用公共函数处理默认值
|
|
102
|
+
const expectedDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
|
|
103
|
+
|
|
104
|
+
// 检查默认值变化
|
|
105
|
+
if (String(existingColumn.defaultValue) !== String(expectedDefault)) {
|
|
106
|
+
changes.push({
|
|
107
|
+
type: 'default',
|
|
108
|
+
current: existingColumn.defaultValue,
|
|
109
|
+
expected: expectedDefault
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return changes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 将表结构计划应用到数据库(执行 DDL/索引/注释等)
|
|
118
|
+
*
|
|
119
|
+
* 根据数据库方言和计划内容,执行相应的 DDL 操作:
|
|
120
|
+
* - SQLite: 新增字段直接 ALTER,其他操作需要重建表
|
|
121
|
+
* - MySQL: 尝试在线 DDL(INSTANT/INPLACE)
|
|
122
|
+
* - PostgreSQL: 直接 ALTER
|
|
123
|
+
*
|
|
124
|
+
* @param sql - SQL 客户端实例
|
|
125
|
+
* @param tableName - 表名
|
|
126
|
+
* @param fields - 字段定义对象
|
|
127
|
+
* @param plan - 表结构变更计划
|
|
128
|
+
*/
|
|
129
|
+
export async function applyTablePlan(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, plan: TablePlan): Promise<void> {
|
|
130
|
+
if (!plan || !plan.changed) return;
|
|
131
|
+
|
|
132
|
+
// SQLite: 仅支持部分 ALTER;需要时走重建
|
|
133
|
+
if (IS_SQLITE) {
|
|
134
|
+
if (plan.modifyClauses.length > 0 || plan.defaultClauses.length > 0) {
|
|
135
|
+
if (IS_PLAN) Logger.debug(`[计划] 重建表 ${tableName} 以应用列修改/默认值变化`);
|
|
136
|
+
else await rebuildSqliteTable(sql, tableName, fields);
|
|
137
|
+
} else {
|
|
138
|
+
for (const c of plan.addClauses) {
|
|
139
|
+
const stmt = `ALTER TABLE "${tableName}" ${c}`;
|
|
140
|
+
if (IS_PLAN) Logger.debug(`[计划] ${stmt}`);
|
|
141
|
+
else await sql.unsafe(stmt);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
const clauses = [...plan.addClauses, ...plan.modifyClauses];
|
|
146
|
+
if (clauses.length > 0) {
|
|
147
|
+
const stmt = buildAlterTableSQL(tableName, clauses);
|
|
148
|
+
if (IS_PLAN) Logger.debug(`[计划] ${stmt}`);
|
|
149
|
+
else if (IS_MYSQL) await executeDDLSafely(sql, stmt);
|
|
150
|
+
else await sql.unsafe(stmt);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 默认值专用 ALTER(SQLite 不支持)
|
|
155
|
+
if (plan.defaultClauses.length > 0) {
|
|
156
|
+
if (IS_SQLITE) {
|
|
157
|
+
Logger.warn(`SQLite 不支持修改默认值,表 ${tableName} 的默认值变更已跳过`);
|
|
158
|
+
} else {
|
|
159
|
+
const stmt = buildAlterTableSQL(tableName, plan.defaultClauses);
|
|
160
|
+
if (IS_PLAN) Logger.debug(`[计划] ${stmt}`);
|
|
161
|
+
else if (IS_MYSQL) await executeDDLSafely(sql, stmt);
|
|
162
|
+
else await sql.unsafe(stmt);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 索引操作
|
|
167
|
+
for (const act of plan.indexActions) {
|
|
168
|
+
const stmt = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
|
|
169
|
+
if (IS_PLAN) {
|
|
170
|
+
Logger.debug(`[计划] ${stmt}`);
|
|
171
|
+
} else {
|
|
172
|
+
try {
|
|
173
|
+
await sql.unsafe(stmt);
|
|
174
|
+
if (act.action === 'create') {
|
|
175
|
+
Logger.debug(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
176
|
+
} else {
|
|
177
|
+
Logger.debug(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
178
|
+
}
|
|
179
|
+
} catch (error: any) {
|
|
180
|
+
Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败`, error);
|
|
181
|
+
Logger.warn(`表名: ${tableName}, 索引名: ${act.indexName}, 字段: ${act.fieldName}`);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// PG 列注释
|
|
188
|
+
if (IS_PG && plan.commentActions && plan.commentActions.length > 0) {
|
|
189
|
+
for (const stmt of plan.commentActions) {
|
|
190
|
+
if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
|
|
191
|
+
else await sql.unsafe(stmt);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 常量定义模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - 数据库类型判断
|
|
6
|
+
* - 版本要求
|
|
7
|
+
* - 数据类型映射
|
|
8
|
+
* - 系统字段定义
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 数据库版本要求
|
|
13
|
+
*/
|
|
14
|
+
export const DB_VERSION_REQUIREMENTS = {
|
|
15
|
+
MYSQL_MIN_MAJOR: 8,
|
|
16
|
+
POSTGRES_MIN_MAJOR: 17,
|
|
17
|
+
SQLITE_MIN_VERSION: '3.50.0',
|
|
18
|
+
SQLITE_MIN_VERSION_NUM: 35000 // 3 * 10000 + 50 * 100
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 系统字段定义(所有表都包含的固定字段)
|
|
23
|
+
*/
|
|
24
|
+
export const SYSTEM_FIELDS = {
|
|
25
|
+
ID: { name: 'id', comment: '主键ID' },
|
|
26
|
+
CREATED_AT: { name: 'created_at', comment: '创建时间' },
|
|
27
|
+
UPDATED_AT: { name: 'updated_at', comment: '更新时间' },
|
|
28
|
+
DELETED_AT: { name: 'deleted_at', comment: '删除时间' },
|
|
29
|
+
STATE: { name: 'state', comment: '状态字段' }
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 需要创建索引的系统字段
|
|
34
|
+
*/
|
|
35
|
+
export const SYSTEM_INDEX_FIELDS = ['created_at', 'updated_at', 'state'] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 字段变更类型的中文标签映射
|
|
39
|
+
*/
|
|
40
|
+
export const CHANGE_TYPE_LABELS = {
|
|
41
|
+
length: '长度',
|
|
42
|
+
datatype: '类型',
|
|
43
|
+
comment: '注释',
|
|
44
|
+
default: '默认值',
|
|
45
|
+
nullable: '可空约束',
|
|
46
|
+
unique: '唯一约束'
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* MySQL 表配置
|
|
51
|
+
*
|
|
52
|
+
* 固定配置说明:
|
|
53
|
+
* - ENGINE: InnoDB(支持事务、外键)
|
|
54
|
+
* - CHARSET: utf8mb4(完整 Unicode 支持,包括 Emoji)
|
|
55
|
+
* - COLLATE: utf8mb4_0900_ai_ci(MySQL 8.0 推荐,不区分重音和大小写)
|
|
56
|
+
*/
|
|
57
|
+
export const MYSQL_TABLE_CONFIG = {
|
|
58
|
+
ENGINE: 'InnoDB',
|
|
59
|
+
CHARSET: 'utf8mb4',
|
|
60
|
+
COLLATE: 'utf8mb4_0900_ai_ci'
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
// 数据库类型判断
|
|
64
|
+
export const DB = (process.env.DB_TYPE || 'mysql').toLowerCase();
|
|
65
|
+
export const IS_MYSQL = DB === 'mysql';
|
|
66
|
+
export const IS_PG = DB === 'postgresql' || DB === 'postgres';
|
|
67
|
+
export const IS_SQLITE = DB === 'sqlite';
|
|
68
|
+
|
|
69
|
+
// 字段类型映射(按方言)
|
|
70
|
+
export const typeMapping = {
|
|
71
|
+
number: IS_SQLITE ? 'INTEGER' : IS_PG ? 'BIGINT' : 'BIGINT',
|
|
72
|
+
string: IS_SQLITE ? 'TEXT' : IS_PG ? 'character varying' : 'VARCHAR',
|
|
73
|
+
text: IS_MYSQL ? 'MEDIUMTEXT' : 'TEXT',
|
|
74
|
+
array_string: IS_SQLITE ? 'TEXT' : IS_PG ? 'character varying' : 'VARCHAR',
|
|
75
|
+
array_text: IS_MYSQL ? 'MEDIUMTEXT' : 'TEXT'
|
|
76
|
+
};
|