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.
- 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 +3 -3
- 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 +1 -4
- package/sync/syncDb/constants.ts +3 -0
- package/sync/syncDb/ddl.ts +2 -1
- package/sync/syncDb/helpers.ts +5 -117
- package/sync/syncDb/sqlite.ts +1 -3
- package/sync/syncDb/table.ts +8 -142
- package/sync/syncDb/tableCreate.ts +25 -9
- package/sync/syncDb/types.ts +125 -0
- package/sync/syncDb/version.ts +0 -3
- package/sync/syncDb.ts +146 -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 +287 -0
- package/tests/syncDb-constants.test.ts +150 -0
- package/tests/syncDb-ddl.test.ts +205 -0
- package/tests/syncDb-helpers.test.ts +112 -0
- package/tests/syncDb-schema.test.ts +178 -0
- package/tests/syncDb-types.test.ts +129 -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/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 {
|
|
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
|
|
109
|
+
const addonPackageJsonPath = getAddonDir(addonName, 'package.json');
|
|
110
110
|
let addonTitle = addonName;
|
|
111
111
|
try {
|
|
112
|
-
const
|
|
113
|
-
addonTitle =
|
|
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
|
|
223
|
+
await Connect.connect(config);
|
|
224
224
|
|
|
225
|
-
const helper =
|
|
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
|
|
262
|
+
await Connect.disconnect();
|
|
263
263
|
}
|
|
264
264
|
}
|
package/sync/syncDb/apply.ts
CHANGED
|
@@ -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
|
*
|
package/sync/syncDb/constants.ts
CHANGED
|
@@ -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';
|
package/sync/syncDb/ddl.ts
CHANGED
|
@@ -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,
|
|
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';
|
package/sync/syncDb/helpers.ts
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 包含:
|
|
5
5
|
* - 标识符引用(反引号/双引号转义)
|
|
6
|
-
* - 默认值处理
|
|
7
6
|
* - 日志输出格式化
|
|
7
|
+
* - 字段默认值应用
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { IS_MYSQL, IS_PG
|
|
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
|
*
|
package/sync/syncDb/sqlite.ts
CHANGED
|
@@ -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
|
*
|
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 { IS_MYSQL, IS_PG,
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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 {
|
|
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 = [];
|
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/sync/syncDb/version.ts
CHANGED
|
@@ -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('.')
|