befly 2.3.2 → 3.0.0
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/apis/health/info.ts +64 -0
- package/apis/tool/tokenCheck.ts +51 -0
- package/bin/befly.ts +202 -0
- package/checks/conflict.ts +408 -0
- package/checks/table.ts +284 -0
- package/config/env.ts +218 -0
- package/config/reserved.ts +96 -0
- package/main.ts +101 -0
- package/package.json +45 -16
- package/plugins/{db.js → db.ts} +25 -12
- package/plugins/logger.ts +28 -0
- package/plugins/redis.ts +51 -0
- package/plugins/tool.ts +34 -0
- package/scripts/syncDb/apply.ts +171 -0
- package/scripts/syncDb/constants.ts +70 -0
- package/scripts/syncDb/ddl.ts +182 -0
- package/scripts/syncDb/helpers.ts +172 -0
- package/scripts/syncDb/index.ts +215 -0
- package/scripts/syncDb/schema.ts +199 -0
- package/scripts/syncDb/sqlite.ts +50 -0
- package/scripts/syncDb/state.ts +104 -0
- package/scripts/syncDb/table.ts +204 -0
- package/scripts/syncDb/tableCreate.ts +142 -0
- package/scripts/syncDb/tests/constants.test.ts +104 -0
- package/scripts/syncDb/tests/ddl.test.ts +134 -0
- package/scripts/syncDb/tests/helpers.test.ts +70 -0
- package/scripts/syncDb/types.ts +92 -0
- package/scripts/syncDb/version.ts +73 -0
- package/scripts/syncDb.ts +9 -0
- package/scripts/syncDev.ts +112 -0
- package/system.ts +149 -0
- package/tables/_common.json +21 -0
- package/tables/admin.json +10 -0
- package/tsconfig.json +58 -0
- package/types/api.d.ts +246 -0
- package/types/befly.d.ts +234 -0
- package/types/common.d.ts +215 -0
- package/types/context.ts +167 -0
- package/types/crypto.d.ts +23 -0
- package/types/database.d.ts +278 -0
- package/types/index.d.ts +16 -0
- package/types/index.ts +459 -0
- package/types/jwt.d.ts +99 -0
- package/types/logger.d.ts +43 -0
- package/types/plugin.d.ts +109 -0
- package/types/redis.d.ts +44 -0
- package/types/tool.d.ts +67 -0
- package/types/validator.d.ts +45 -0
- package/utils/addonHelper.ts +60 -0
- package/utils/api.ts +23 -0
- package/utils/{colors.js → colors.ts} +79 -21
- package/utils/crypto.ts +308 -0
- package/utils/datetime.ts +51 -0
- package/utils/dbHelper.ts +142 -0
- package/utils/errorHandler.ts +68 -0
- package/utils/index.ts +46 -0
- package/utils/jwt.ts +493 -0
- package/utils/logger.ts +284 -0
- package/utils/objectHelper.ts +68 -0
- package/utils/pluginHelper.ts +62 -0
- package/utils/redisHelper.ts +338 -0
- package/utils/response.ts +38 -0
- package/utils/{sqlBuilder.js → sqlBuilder.ts} +233 -97
- package/utils/sqlHelper.ts +447 -0
- package/utils/tableHelper.ts +167 -0
- package/utils/tool.ts +230 -0
- package/utils/typeHelper.ts +101 -0
- package/utils/validate.ts +451 -0
- package/utils/{xml.js → xml.ts} +100 -74
- package/.npmrc +0 -3
- package/.prettierignore +0 -2
- package/.prettierrc +0 -11
- package/apis/health/info.js +0 -49
- package/apis/tool/tokenCheck.js +0 -29
- package/checks/table.js +0 -221
- package/config/env.js +0 -62
- package/main.js +0 -579
- package/plugins/logger.js +0 -14
- package/plugins/redis.js +0 -32
- package/plugins/tool.js +0 -8
- package/scripts/syncDb.js +0 -603
- package/system.js +0 -118
- package/tables/common.json +0 -16
- package/tables/tool.json +0 -6
- package/utils/api.js +0 -27
- package/utils/crypto.js +0 -260
- package/utils/index.js +0 -387
- package/utils/jwt.js +0 -387
- package/utils/logger.js +0 -143
- package/utils/redisHelper.js +0 -74
- package/utils/sqlManager.js +0 -471
- package/utils/tool.js +0 -31
- package/utils/validate.js +0 -228
package/plugins/tool.js
DELETED
package/scripts/syncDb.js
DELETED
|
@@ -1,603 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 数据库表结构同步脚本 - 仅支持 MySQL 8.0+
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { Env } from '../config/env.js';
|
|
7
|
-
import { Logger } from '../utils/logger.js';
|
|
8
|
-
import { parseFieldRule, createSqlClient, toSnakeTableName } from '../utils/index.js';
|
|
9
|
-
import { __dirtables, getProjectDir } from '../system.js';
|
|
10
|
-
import { checkTable } from '../checks/table.js';
|
|
11
|
-
|
|
12
|
-
const typeMapping = {
|
|
13
|
-
number: 'BIGINT',
|
|
14
|
-
string: 'VARCHAR',
|
|
15
|
-
text: 'MEDIUMTEXT',
|
|
16
|
-
array: 'VARCHAR'
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
// 表名转换函数已移动至 utils/index.js 的 toSnakeTableName
|
|
20
|
-
|
|
21
|
-
// 环境开关读取(支持未在 Env 显式声明的变量,默认值兜底)
|
|
22
|
-
const getFlag = (val, def = 0) => {
|
|
23
|
-
if (val === undefined || val === null || val === '') return !!def;
|
|
24
|
-
const n = Number(val);
|
|
25
|
-
if (!Number.isNaN(n)) return n !== 0;
|
|
26
|
-
const s = String(val).toLowerCase();
|
|
27
|
-
return s === 'true' || s === 'on' || s === 'yes';
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// 命令行参数
|
|
31
|
-
const ARGV = Array.isArray(process.argv) ? process.argv : [];
|
|
32
|
-
const CLI = {
|
|
33
|
-
DRY_RUN: ARGV.includes('--dry-run')
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const FLAGS = {
|
|
37
|
-
// DRY-RUN 改为命令行参数控制,忽略环境变量
|
|
38
|
-
DRY_RUN: CLI.DRY_RUN, // 仅打印计划,不执行
|
|
39
|
-
MERGE_ALTER: getFlag(Env.SYNC_MERGE_ALTER, 1), // 合并每表多项 DDL
|
|
40
|
-
ONLINE_INDEX: getFlag(Env.SYNC_ONLINE_INDEX, 1), // 索引操作使用在线算法
|
|
41
|
-
DISALLOW_SHRINK: getFlag(Env.SYNC_DISALLOW_SHRINK, 1), // 禁止长度收缩
|
|
42
|
-
ALLOW_TYPE_CHANGE: getFlag(Env.SYNC_ALLOW_TYPE_CHANGE, 0) // 允许类型变更
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// 计算期望的默认值(与 information_schema 返回的值对齐)
|
|
46
|
-
// 规则:当默认值为 'null' 时按类型提供默认值:number→0,string→"",array→"[]";text 永不设置默认值
|
|
47
|
-
const getExpectedDefault = (fieldType, fieldDefaultValue) => {
|
|
48
|
-
if (fieldType === 'text') return null; // TEXT 不设置默认
|
|
49
|
-
if (fieldDefaultValue !== undefined && fieldDefaultValue !== null && fieldDefaultValue !== 'null') {
|
|
50
|
-
return fieldDefaultValue; // 保留显式默认值(数字或字符串,包含空字符串)
|
|
51
|
-
}
|
|
52
|
-
// 规则为 'null' 时的内置默认
|
|
53
|
-
switch (fieldType) {
|
|
54
|
-
case 'number':
|
|
55
|
-
return 0;
|
|
56
|
-
case 'string':
|
|
57
|
-
return '';
|
|
58
|
-
case 'array':
|
|
59
|
-
return '[]';
|
|
60
|
-
default:
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const normalizeDefault = (val) => (val === null || val === undefined ? null : String(val));
|
|
66
|
-
|
|
67
|
-
// 获取字段的SQL定义
|
|
68
|
-
const getColumnDefinition = (fieldName, rule) => {
|
|
69
|
-
const [fieldDisplayName, fieldType, fieldMin, fieldMaxLength, fieldDefaultValue, fieldHasIndex] = parseFieldRule(rule);
|
|
70
|
-
|
|
71
|
-
let sqlType = typeMapping[fieldType];
|
|
72
|
-
if (!sqlType) throw new Error(`不支持的数据类型: ${fieldType}`);
|
|
73
|
-
|
|
74
|
-
// 根据字段类型设置SQL类型和长度
|
|
75
|
-
if (fieldType === 'string' || fieldType === 'array') {
|
|
76
|
-
const maxLength = parseInt(fieldMaxLength);
|
|
77
|
-
sqlType = `VARCHAR(${maxLength})`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 统一强制 NOT NULL
|
|
81
|
-
let columnDef = `\`${fieldName}\` ${sqlType} NOT NULL`;
|
|
82
|
-
|
|
83
|
-
// 设置默认值:类型非 text 时总是设置(显式默认或内置默认)
|
|
84
|
-
const expectedDefault = getExpectedDefault(fieldType, fieldDefaultValue);
|
|
85
|
-
if (fieldType !== 'text' && expectedDefault !== null) {
|
|
86
|
-
if (fieldType === 'number') {
|
|
87
|
-
columnDef += ` DEFAULT ${expectedDefault}`;
|
|
88
|
-
} else {
|
|
89
|
-
columnDef += ` DEFAULT \"${String(expectedDefault).replace(/\"/g, '\\"')}\"`;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
// text 类型不设置默认值
|
|
93
|
-
|
|
94
|
-
// 添加字段注释(使用第1个属性作为字段显示名称)
|
|
95
|
-
if (fieldDisplayName && fieldDisplayName !== 'null') {
|
|
96
|
-
columnDef += ` COMMENT "${fieldDisplayName.replace(/"/g, '\\"')}"`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return columnDef;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
// 通用执行器:直接使用 Bun SQL 参数化(MySQL 使用 '?' 占位符)
|
|
103
|
-
const exec = async (client, query, params = []) => {
|
|
104
|
-
if (params && params.length > 0) {
|
|
105
|
-
return await client.unsafe(query, params);
|
|
106
|
-
}
|
|
107
|
-
return await client.unsafe(query);
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// 获取表的现有列信息
|
|
111
|
-
const getTableColumns = async (client, tableName) => {
|
|
112
|
-
const result = await exec(
|
|
113
|
-
client,
|
|
114
|
-
`SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
|
|
115
|
-
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
|
|
116
|
-
[Env.MYSQL_DB || 'test', tableName]
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
const columns = {};
|
|
120
|
-
result.forEach((row) => {
|
|
121
|
-
columns[row.COLUMN_NAME] = {
|
|
122
|
-
type: row.DATA_TYPE,
|
|
123
|
-
columnType: row.COLUMN_TYPE,
|
|
124
|
-
length: row.CHARACTER_MAXIMUM_LENGTH,
|
|
125
|
-
nullable: row.IS_NULLABLE === 'YES',
|
|
126
|
-
defaultValue: row.COLUMN_DEFAULT,
|
|
127
|
-
comment: row.COLUMN_COMMENT
|
|
128
|
-
};
|
|
129
|
-
});
|
|
130
|
-
return columns;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
// 获取表的现有索引信息
|
|
134
|
-
const getTableIndexes = async (client, tableName) => {
|
|
135
|
-
const result = await exec(
|
|
136
|
-
client,
|
|
137
|
-
`SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
|
|
138
|
-
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME != 'PRIMARY' ORDER BY INDEX_NAME`,
|
|
139
|
-
[Env.MYSQL_DB || 'test', tableName]
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
const indexes = {};
|
|
143
|
-
result.forEach((row) => {
|
|
144
|
-
if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
|
|
145
|
-
indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
|
|
146
|
-
});
|
|
147
|
-
return indexes;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// 构建索引操作 SQL(统一使用 ALTER TABLE 并尽量在线)
|
|
151
|
-
const buildIndexSQL = (tableName, indexName, fieldName, action) => {
|
|
152
|
-
const parts = [];
|
|
153
|
-
if (action === 'create') {
|
|
154
|
-
parts.push(`ADD INDEX \`${indexName}\` (\`${fieldName}\`)`);
|
|
155
|
-
} else {
|
|
156
|
-
parts.push(`DROP INDEX \`${indexName}\``);
|
|
157
|
-
}
|
|
158
|
-
if (FLAGS.ONLINE_INDEX) {
|
|
159
|
-
parts.push('ALGORITHM=INPLACE');
|
|
160
|
-
parts.push('LOCK=NONE');
|
|
161
|
-
}
|
|
162
|
-
return `ALTER TABLE \`${tableName}\` ${parts.join(', ')}`;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
// 创建表
|
|
166
|
-
const createTable = async (client, tableName, fields) => {
|
|
167
|
-
const columns = [
|
|
168
|
-
//
|
|
169
|
-
'`id` BIGINT PRIMARY KEY COMMENT "主键ID"',
|
|
170
|
-
'`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT "创建时间"',
|
|
171
|
-
'`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT "更新时间"',
|
|
172
|
-
'`deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT "删除时间"',
|
|
173
|
-
'`state` BIGINT NOT NULL DEFAULT 0 COMMENT "状态字段"'
|
|
174
|
-
];
|
|
175
|
-
|
|
176
|
-
const indexes = [
|
|
177
|
-
//
|
|
178
|
-
'INDEX `idx_created_at` (`created_at`)',
|
|
179
|
-
'INDEX `idx_updated_at` (`updated_at`)',
|
|
180
|
-
'INDEX `idx_state` (`state`)'
|
|
181
|
-
];
|
|
182
|
-
|
|
183
|
-
// 添加自定义字段和索引
|
|
184
|
-
for (const [fieldName, rule] of Object.entries(fields)) {
|
|
185
|
-
columns.push(getColumnDefinition(fieldName, rule));
|
|
186
|
-
|
|
187
|
-
// 使用第6个属性判断是否设置索引
|
|
188
|
-
const ruleParts = parseFieldRule(rule);
|
|
189
|
-
const fieldHasIndex = ruleParts[5]; // 第6个属性
|
|
190
|
-
if (fieldHasIndex === '1') {
|
|
191
|
-
indexes.push(`INDEX \`idx_${fieldName}\` (\`${fieldName}\`)`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const createTableSQL = `
|
|
196
|
-
CREATE TABLE \`${tableName}\` (
|
|
197
|
-
${columns.join(',\n ')},
|
|
198
|
-
${indexes.join(',\n ')}
|
|
199
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs
|
|
200
|
-
`;
|
|
201
|
-
|
|
202
|
-
if (FLAGS.DRY_RUN) {
|
|
203
|
-
Logger.info(`[计划] ${createTableSQL.replace(/\n+/g, ' ')}`);
|
|
204
|
-
} else {
|
|
205
|
-
await exec(client, createTableSQL);
|
|
206
|
-
Logger.info(`[新建表] ${tableName}`);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
// 比较字段定义变化
|
|
211
|
-
const compareFieldDefinition = (existingColumn, newRule, fieldName) => {
|
|
212
|
-
const ruleParts = parseFieldRule(newRule);
|
|
213
|
-
const [fieldDisplayName, fieldType, fieldMin, fieldMaxLength, fieldDefaultValue] = ruleParts;
|
|
214
|
-
const changes = [];
|
|
215
|
-
|
|
216
|
-
// 检查长度变化(string和array类型)
|
|
217
|
-
if (fieldType === 'string' || fieldType === 'array') {
|
|
218
|
-
if (fieldMaxLength === 'null') {
|
|
219
|
-
throw new Error(`string/array 类型字段的最大长度未设置,必须指定最大长度`);
|
|
220
|
-
}
|
|
221
|
-
const newMaxLength = parseInt(fieldMaxLength);
|
|
222
|
-
if (existingColumn.length !== newMaxLength) {
|
|
223
|
-
changes.push({ type: 'length', current: existingColumn.length, new: newMaxLength });
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// 检查注释变化(使用第1个属性作为字段显示名称)
|
|
228
|
-
if (fieldDisplayName && fieldDisplayName !== 'null') {
|
|
229
|
-
const currentComment = existingColumn.comment || '';
|
|
230
|
-
if (currentComment !== fieldDisplayName) {
|
|
231
|
-
changes.push({ type: 'comment', current: currentComment, new: fieldDisplayName });
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// 检查数据类型变化
|
|
236
|
-
const expectedDbType = {
|
|
237
|
-
number: 'bigint',
|
|
238
|
-
string: 'varchar',
|
|
239
|
-
text: 'mediumtext',
|
|
240
|
-
array: 'varchar'
|
|
241
|
-
}[fieldType];
|
|
242
|
-
|
|
243
|
-
if (existingColumn.type.toLowerCase() !== expectedDbType) {
|
|
244
|
-
changes.push({ type: 'datatype', current: existingColumn.type, new: expectedDbType });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// 检查默认值变化(按照生成规则推导期望默认值)
|
|
248
|
-
const expectedDefault = getExpectedDefault(fieldType, fieldDefaultValue);
|
|
249
|
-
const currDef = normalizeDefault(existingColumn.defaultValue);
|
|
250
|
-
const newDef = normalizeDefault(expectedDefault);
|
|
251
|
-
if (currDef !== newDef) {
|
|
252
|
-
changes.push({ type: 'default', current: existingColumn.defaultValue, new: expectedDefault });
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// 检查可空性变化(统一期望 NOT NULL)
|
|
256
|
-
const expectedNullable = false; // 期望 NOT NULL
|
|
257
|
-
if (existingColumn.nullable !== expectedNullable) {
|
|
258
|
-
// existingColumn.nullable 为 true 表示可空
|
|
259
|
-
changes.push({
|
|
260
|
-
type: 'nullability',
|
|
261
|
-
current: existingColumn.nullable ? 'NULL' : 'NOT NULL',
|
|
262
|
-
new: expectedNullable ? 'NULL' : 'NOT NULL'
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return { hasChanges: changes.length > 0, changes };
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
// 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
|
|
270
|
-
const generateDDLClause = (fieldName, rule, isAdd = false) => {
|
|
271
|
-
const columnDef = getColumnDefinition(fieldName, rule);
|
|
272
|
-
const operation = isAdd ? 'ADD COLUMN' : 'MODIFY COLUMN';
|
|
273
|
-
return `${operation} ${columnDef}`;
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
// 安全执行DDL语句
|
|
277
|
-
const executeDDLSafely = async (client, sql) => {
|
|
278
|
-
try {
|
|
279
|
-
await exec(client, sql);
|
|
280
|
-
return true;
|
|
281
|
-
} catch (error) {
|
|
282
|
-
// INSTANT失败时尝试INPLACE
|
|
283
|
-
if (sql.includes('ALGORITHM=INSTANT')) {
|
|
284
|
-
const inplaceSql = sql.replace(/ALGORITHM=INSTANT/g, 'ALGORITHM=INPLACE');
|
|
285
|
-
try {
|
|
286
|
-
await exec(client, inplaceSql);
|
|
287
|
-
return true;
|
|
288
|
-
} catch (inplaceError) {
|
|
289
|
-
// 最后尝试传统DDL:移除 ALGORITHM/LOCK 附加子句后执行
|
|
290
|
-
const traditionSql = sql
|
|
291
|
-
.replace(/,\s*ALGORITHM=INPLACE/g, '')
|
|
292
|
-
.replace(/,\s*ALGORITHM=INSTANT/g, '')
|
|
293
|
-
.replace(/,\s*LOCK=(NONE|SHARED|EXCLUSIVE)/g, '');
|
|
294
|
-
await exec(client, traditionSql);
|
|
295
|
-
return true;
|
|
296
|
-
}
|
|
297
|
-
} else {
|
|
298
|
-
throw error;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
// 同步表结构
|
|
304
|
-
const syncTable = async (client, tableName, fields) => {
|
|
305
|
-
const existingColumns = await getTableColumns(client, tableName);
|
|
306
|
-
const existingIndexes = await getTableIndexes(client, tableName);
|
|
307
|
-
const systemFields = ['id', 'created_at', 'updated_at', 'deleted_at', 'state'];
|
|
308
|
-
let changed = false;
|
|
309
|
-
|
|
310
|
-
const addClauses = [];
|
|
311
|
-
const modifyClauses = [];
|
|
312
|
-
const defaultClauses = [];
|
|
313
|
-
const indexActions = [];
|
|
314
|
-
// 变更统计(按字段粒度)
|
|
315
|
-
const changeStats = {
|
|
316
|
-
addFields: 0,
|
|
317
|
-
datatype: 0,
|
|
318
|
-
length: 0,
|
|
319
|
-
default: 0,
|
|
320
|
-
comment: 0,
|
|
321
|
-
nullability: 0,
|
|
322
|
-
indexCreate: 0,
|
|
323
|
-
indexDrop: 0
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
// 同步字段
|
|
327
|
-
for (const [fieldName, rule] of Object.entries(fields)) {
|
|
328
|
-
if (existingColumns[fieldName]) {
|
|
329
|
-
const comparison = compareFieldDefinition(existingColumns[fieldName], rule, fieldName);
|
|
330
|
-
if (comparison.hasChanges) {
|
|
331
|
-
// 打印具体变动项并统计
|
|
332
|
-
for (const c of comparison.changes) {
|
|
333
|
-
const label = { length: '长度', datatype: '类型', comment: '注释', default: '默认值' }[c.type] || c.type;
|
|
334
|
-
Logger.info(`[字段变更] ${tableName}.${fieldName} ${label}: ${c.current ?? 'NULL'} -> ${c.new ?? 'NULL'}`);
|
|
335
|
-
if (c.type in changeStats) changeStats[c.type]++;
|
|
336
|
-
}
|
|
337
|
-
// 风险护栏:长度收缩/类型变更
|
|
338
|
-
const ruleParts = parseFieldRule(rule);
|
|
339
|
-
const [, fType, , fMax, fDef] = ruleParts;
|
|
340
|
-
if ((fType === 'string' || fType === 'array') && existingColumns[fieldName].length && fMax !== 'null') {
|
|
341
|
-
const newLen = parseInt(fMax);
|
|
342
|
-
if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
|
|
343
|
-
Logger.warn(`[跳过危险变更] ${tableName}.${fieldName} 长度收缩 ${existingColumns[fieldName].length} -> ${newLen} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
|
|
344
|
-
// 如果仅有 shrink 一个变化,仍可能还有默认/注释变化要处理
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
const expectedDbType = {
|
|
348
|
-
number: 'bigint',
|
|
349
|
-
string: 'varchar',
|
|
350
|
-
text: 'mediumtext',
|
|
351
|
-
array: 'varchar'
|
|
352
|
-
}[parseFieldRule(rule)[1]];
|
|
353
|
-
if (existingColumns[fieldName].type.toLowerCase() !== expectedDbType && !FLAGS.ALLOW_TYPE_CHANGE) {
|
|
354
|
-
Logger.warn(`[跳过危险变更] ${tableName}.${fieldName} 类型变更 ${existingColumns[fieldName].type} -> ${expectedDbType} 已被跳过(设置 SYNC_ALLOW_TYPE_CHANGE=1 可放开)`);
|
|
355
|
-
// 继续处理默认值/注释等非类型变更
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// 判断是否“仅默认值变化”
|
|
359
|
-
const onlyDefaultChanged = comparison.changes.every((c) => c.type === 'default');
|
|
360
|
-
if (onlyDefaultChanged) {
|
|
361
|
-
const expectedDefault = getExpectedDefault(parseFieldRule(rule)[1], parseFieldRule(rule)[4]);
|
|
362
|
-
if (expectedDefault === null) {
|
|
363
|
-
defaultClauses.push(`ALTER COLUMN \`${fieldName}\` DROP DEFAULT`);
|
|
364
|
-
} else {
|
|
365
|
-
const isNumber = parseFieldRule(rule)[1] === 'number';
|
|
366
|
-
const v = isNumber ? expectedDefault : `"${String(expectedDefault).replace(/"/g, '\\"')}"`;
|
|
367
|
-
defaultClauses.push(`ALTER COLUMN \`${fieldName}\` SET DEFAULT ${v}`);
|
|
368
|
-
}
|
|
369
|
-
} else {
|
|
370
|
-
// 判断是否需要跳过 MODIFY:包含收缩或类型变更时跳过
|
|
371
|
-
let skipModify = false;
|
|
372
|
-
const hasLengthChange = comparison.changes.some((c) => c.type === 'length');
|
|
373
|
-
if (hasLengthChange && (fType === 'string' || fType === 'array') && existingColumns[fieldName].length && fMax !== 'null') {
|
|
374
|
-
const newLen = parseInt(fMax);
|
|
375
|
-
if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
|
|
376
|
-
skipModify = true;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const hasTypeChange = comparison.changes.some((c) => c.type === 'datatype');
|
|
380
|
-
if (hasTypeChange && !FLAGS.ALLOW_TYPE_CHANGE) {
|
|
381
|
-
skipModify = true;
|
|
382
|
-
}
|
|
383
|
-
if (!skipModify) {
|
|
384
|
-
// 合并到 MODIFY COLUMN 子句
|
|
385
|
-
modifyClauses.push(generateDDLClause(fieldName, rule, false));
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
changed = true;
|
|
389
|
-
}
|
|
390
|
-
} else {
|
|
391
|
-
// 新增字段日志
|
|
392
|
-
const [disp, fType, fMin, fMax, fDef, fIdx] = parseFieldRule(rule);
|
|
393
|
-
const lenPart = fType === 'string' || fType === 'array' ? ` 长度:${parseInt(fMax)}` : '';
|
|
394
|
-
const expectedDefault = getExpectedDefault(fType, fDef);
|
|
395
|
-
Logger.info(`[新增字段] ${tableName}.${fieldName} 类型:${fType}${lenPart} 默认:${expectedDefault ?? 'NULL'}`);
|
|
396
|
-
addClauses.push(generateDDLClause(fieldName, rule, true));
|
|
397
|
-
changed = true;
|
|
398
|
-
changeStats.addFields++;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// 同步索引
|
|
403
|
-
for (const [fieldName, rule] of Object.entries(fields)) {
|
|
404
|
-
const ruleParts = parseFieldRule(rule);
|
|
405
|
-
const fieldHasIndex = ruleParts[5]; // 使用第6个属性判断是否设置索引
|
|
406
|
-
const indexName = `idx_${fieldName}`;
|
|
407
|
-
|
|
408
|
-
if (fieldHasIndex === '1' && !existingIndexes[indexName]) {
|
|
409
|
-
indexActions.push({ action: 'create', indexName, fieldName });
|
|
410
|
-
changed = true;
|
|
411
|
-
changeStats.indexCreate++;
|
|
412
|
-
} else if (fieldHasIndex !== '1' && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
|
|
413
|
-
indexActions.push({ action: 'drop', indexName, fieldName });
|
|
414
|
-
changed = true;
|
|
415
|
-
changeStats.indexDrop++;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
return { changed, addClauses, modifyClauses, defaultClauses, indexActions, metrics: changeStats };
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
// 主同步函数
|
|
422
|
-
const SyncDb = async () => {
|
|
423
|
-
let client = null;
|
|
424
|
-
|
|
425
|
-
try {
|
|
426
|
-
Logger.info('开始数据库表结构同步...');
|
|
427
|
-
|
|
428
|
-
// 验证表定义文件
|
|
429
|
-
const tableValidationResult = await checkTable();
|
|
430
|
-
if (!tableValidationResult) {
|
|
431
|
-
throw new Error('表定义验证失败');
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// 建立数据库连接并检查版本(统一工具函数)
|
|
435
|
-
client = await createSqlClient({ max: 1 });
|
|
436
|
-
const result = await client`SELECT VERSION() AS version`;
|
|
437
|
-
const version = result[0].version;
|
|
438
|
-
|
|
439
|
-
if (version.toLowerCase().includes('mariadb')) {
|
|
440
|
-
throw new Error('此脚本仅支持 MySQL 8.0+,不支持 MariaDB');
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const majorVersion = parseInt(version.split('.')[0]);
|
|
444
|
-
if (majorVersion < 8) {
|
|
445
|
-
throw new Error(`此脚本仅支持 MySQL 8.0+,当前版本: ${version}`);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
Logger.info(`MySQL 版本检查通过: ${version}`);
|
|
449
|
-
|
|
450
|
-
// 扫描并处理表文件
|
|
451
|
-
const tablesGlob = new Bun.Glob('*.json');
|
|
452
|
-
const directories = [__dirtables, getProjectDir('tables')];
|
|
453
|
-
let processedCount = 0;
|
|
454
|
-
let createdTables = 0;
|
|
455
|
-
let modifiedTables = 0;
|
|
456
|
-
// 全局统计
|
|
457
|
-
const overall = {
|
|
458
|
-
addFields: 0,
|
|
459
|
-
typeChanges: 0,
|
|
460
|
-
maxChanges: 0, // 映射为长度变化
|
|
461
|
-
minChanges: 0, // 最小值不参与 DDL,比对保留为0
|
|
462
|
-
defaultChanges: 0,
|
|
463
|
-
nameChanges: 0, // 字段显示名(注释)变更
|
|
464
|
-
indexCreate: 0,
|
|
465
|
-
indexDrop: 0
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
for (const dir of directories) {
|
|
469
|
-
try {
|
|
470
|
-
for await (const file of tablesGlob.scan({ cwd: dir, absolute: true, onlyFiles: true })) {
|
|
471
|
-
const fileBaseName = path.basename(file, '.json');
|
|
472
|
-
const tableName = toSnakeTableName(fileBaseName);
|
|
473
|
-
const tableDefinition = await Bun.file(file).json();
|
|
474
|
-
const result = await exec(client, 'SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', [Env.MYSQL_DB || 'test', tableName]);
|
|
475
|
-
const exists = result[0].count > 0;
|
|
476
|
-
|
|
477
|
-
if (exists) {
|
|
478
|
-
const plan = await syncTable(client, tableName, tableDefinition);
|
|
479
|
-
if (plan.changed) {
|
|
480
|
-
// 汇总统计
|
|
481
|
-
if (plan.metrics) {
|
|
482
|
-
overall.addFields += plan.metrics.addFields;
|
|
483
|
-
overall.typeChanges += plan.metrics.datatype;
|
|
484
|
-
overall.maxChanges += plan.metrics.length;
|
|
485
|
-
overall.defaultChanges += plan.metrics.default;
|
|
486
|
-
overall.indexCreate += plan.metrics.indexCreate;
|
|
487
|
-
overall.indexDrop += plan.metrics.indexDrop;
|
|
488
|
-
overall.nameChanges += plan.metrics.comment;
|
|
489
|
-
}
|
|
490
|
-
// 合并执行 ALTER TABLE 子句
|
|
491
|
-
if (FLAGS.MERGE_ALTER) {
|
|
492
|
-
const clauses = [...plan.addClauses, ...plan.modifyClauses];
|
|
493
|
-
if (clauses.length > 0) {
|
|
494
|
-
const sql = `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
495
|
-
if (FLAGS.DRY_RUN) {
|
|
496
|
-
Logger.info(`[计划] ${sql}`);
|
|
497
|
-
} else {
|
|
498
|
-
await executeDDLSafely(client, sql);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
} else {
|
|
502
|
-
// 分别执行
|
|
503
|
-
for (const c of plan.addClauses) {
|
|
504
|
-
const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
505
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
506
|
-
else await executeDDLSafely(client, sql);
|
|
507
|
-
}
|
|
508
|
-
for (const c of plan.modifyClauses) {
|
|
509
|
-
const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
510
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
511
|
-
else await executeDDLSafely(client, sql);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// 默认值专用 ALTER
|
|
516
|
-
if (plan.defaultClauses.length > 0) {
|
|
517
|
-
const sql = `ALTER TABLE \`${tableName}\` ${plan.defaultClauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
518
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
519
|
-
else await executeDDLSafely(client, sql);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// 索引操作
|
|
523
|
-
for (const act of plan.indexActions) {
|
|
524
|
-
const sql = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
|
|
525
|
-
if (FLAGS.DRY_RUN) {
|
|
526
|
-
Logger.info(`[计划] ${sql}`);
|
|
527
|
-
} else {
|
|
528
|
-
try {
|
|
529
|
-
await exec(client, sql);
|
|
530
|
-
if (act.action === 'create') {
|
|
531
|
-
Logger.info(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
532
|
-
} else {
|
|
533
|
-
Logger.info(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
534
|
-
}
|
|
535
|
-
} catch (error) {
|
|
536
|
-
Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败: ${error.message}`);
|
|
537
|
-
throw error;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
modifiedTables++;
|
|
543
|
-
}
|
|
544
|
-
} else {
|
|
545
|
-
await createTable(client, tableName, tableDefinition);
|
|
546
|
-
createdTables++;
|
|
547
|
-
// 新建表已算作变更
|
|
548
|
-
modifiedTables += 0;
|
|
549
|
-
// 创建表统计:按需求仅汇总创建表数量
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
processedCount++;
|
|
553
|
-
}
|
|
554
|
-
} catch (error) {
|
|
555
|
-
Logger.warn(`扫描目录 ${dir} 出错: ${error.message}`);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// 显示统计信息(扩展维度)
|
|
560
|
-
Logger.info(`统计 - 创建表: ${createdTables}`);
|
|
561
|
-
Logger.info(`统计 - 字段新增: ${overall.addFields}`);
|
|
562
|
-
Logger.info(`统计 - 字段名称变更: ${overall.nameChanges}`);
|
|
563
|
-
Logger.info(`统计 - 字段类型变更: ${overall.typeChanges}`);
|
|
564
|
-
Logger.info(`统计 - 字段最小值变更: ${overall.minChanges}`);
|
|
565
|
-
Logger.info(`统计 - 字段最大值变更: ${overall.maxChanges}`);
|
|
566
|
-
Logger.info(`统计 - 字段默认值变更: ${overall.defaultChanges}`);
|
|
567
|
-
// 索引新增/删除分别打印
|
|
568
|
-
Logger.info(`统计 - 索引新增: ${overall.indexCreate}`);
|
|
569
|
-
Logger.info(`统计 - 索引删除: ${overall.indexDrop}`);
|
|
570
|
-
|
|
571
|
-
if (processedCount === 0) {
|
|
572
|
-
Logger.warn('没有找到任何表定义文件');
|
|
573
|
-
}
|
|
574
|
-
} catch (error) {
|
|
575
|
-
Logger.error(`数据库同步失败: ${error.message}`);
|
|
576
|
-
Logger.error(`错误详情: ${error.stack}`);
|
|
577
|
-
if (error.code) {
|
|
578
|
-
Logger.error(`错误代码: ${error.code}`);
|
|
579
|
-
}
|
|
580
|
-
if (error.errno) {
|
|
581
|
-
Logger.error(`错误编号: ${error.errno}`);
|
|
582
|
-
}
|
|
583
|
-
process.exit(1);
|
|
584
|
-
} finally {
|
|
585
|
-
if (client) {
|
|
586
|
-
try {
|
|
587
|
-
await client.close();
|
|
588
|
-
} catch (error) {
|
|
589
|
-
Logger.warn('关闭数据库连接时出错:', error.message);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
// 如果直接运行此脚本
|
|
596
|
-
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('dbSync.js')) {
|
|
597
|
-
SyncDb().catch((error) => {
|
|
598
|
-
console.error('❌ 数据库同步失败:', error);
|
|
599
|
-
process.exit(1);
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
export { SyncDb };
|