befly 3.76.1 → 3.76.2
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/Befly.js +159 -0
- package/README.md +50 -11
- package/apis/admin/delete.js +1 -3
- package/apis/admin/select.js +2 -7
- package/apis/admin/update.js +1 -5
- package/apis/api/select.js +2 -7
- package/apis/auth/login.js +16 -20
- package/apis/dict/delete.js +1 -1
- package/apis/dict/detail.js +1 -3
- package/apis/dict/select.js +1 -2
- package/apis/dict/update.js +1 -1
- package/apis/dictType/delete.js +1 -1
- package/apis/dictType/detail.js +1 -1
- package/apis/dictType/insert.js +1 -1
- package/apis/dictType/select.js +2 -7
- package/apis/dictType/update.js +2 -2
- package/apis/email/config.js +4 -7
- package/apis/email/logList.js +2 -7
- package/apis/email/send.js +1 -2
- package/apis/email/verify.js +1 -3
- package/apis/loginLog/select.js +2 -7
- package/apis/menu/select.js +2 -7
- package/apis/operateLog/select.js +2 -7
- package/apis/role/apiSave.js +2 -2
- package/apis/role/menuSave.js +2 -2
- package/apis/role/select.js +2 -7
- package/apis/source/imageList.js +2 -5
- package/apis/tongJi/dailyReport.js +12 -13
- package/apis/tongJi/dailyReportSelect.js +1 -2
- package/apis/tongJi/dailyStats.js +1 -1
- package/apis/tongJi/dailyStatsDistribution.js +2 -2
- package/apis/tongJi/errorList.js +1 -2
- package/apis/tongJi/errorReport.js +1 -2
- package/apis/tongJi/projectDelete.js +1 -3
- package/apis/tongJi/projectSelect.js +2 -4
- package/apis/tongJi/projectUpdate.js +2 -2
- package/apis/upload/file.js +7 -5
- package/checks/api.js +43 -66
- package/checks/config.js +17 -12
- package/checks/corns.js +28 -46
- package/checks/field.js +18 -0
- package/checks/items.js +16 -60
- package/checks/menu.js +28 -39
- package/checks/table.js +106 -26
- package/checks/validation.js +13 -0
- package/configs/beflyConfig.json +6 -6
- package/configs/constConfig.js +2 -63
- package/exports.js +2 -0
- package/hooks/auth.js +11 -7
- package/hooks/parser.js +55 -59
- package/hooks/permission.js +1 -0
- package/hooks/rateLimit.js +25 -62
- package/hooks/validator.js +43 -31
- package/index.js +43 -344
- package/{lib → libs}/cacheHelper.js +3 -8
- package/libs/cron.js +60 -0
- package/libs/logger/index.js +1 -0
- package/libs/logger/logger.js +245 -0
- package/libs/logger/sanitize.js +115 -0
- package/{lib → libs/mysql}/dbHelper.js +100 -73
- package/{lib → libs/mysql}/dbParse.js +16 -9
- package/{lib → libs/mysql}/dbUtil.js +15 -32
- package/libs/mysql/error.js +29 -0
- package/libs/mysql/index.js +6 -0
- package/libs/mysql/is.js +29 -0
- package/libs/mysql/mysql.js +43 -0
- package/libs/mysql/sql/identifier.js +85 -0
- package/libs/mysql/sql/index.js +2 -0
- package/{lib → libs/mysql/sql}/sqlBuilder.js +2 -3
- package/libs/mysql/util.js +44 -0
- package/libs/redis/index.js +2 -0
- package/libs/redis/redis.js +204 -0
- package/libs/redis/redisError.js +8 -0
- package/libs/tableModel.js +72 -0
- package/libs/tool.js +33 -0
- package/libs/validator/compiler.js +248 -0
- package/libs/validator/index.js +10 -0
- package/libs/validator/parser.js +173 -0
- package/libs/validator/util.js +5 -0
- package/package.json +10 -8
- package/paths.js +2 -3
- package/plugins/cache.js +2 -1
- package/plugins/email.js +4 -4
- package/plugins/mysql.js +5 -15
- package/plugins/redis.js +4 -12
- package/router/api.js +14 -10
- package/router/root.js +14 -0
- package/router/static.js +3 -3
- package/schemas/api.json +99 -0
- package/schemas/config.json +255 -0
- package/schemas/corn.json +39 -0
- package/schemas/item.json +43 -0
- package/schemas/menu.json +44 -0
- package/schemas/table.json +80 -0
- package/scripts/syncDb/apply.js +87 -0
- package/scripts/syncDb/ddl.js +43 -0
- package/scripts/syncDb/index.js +175 -30
- package/scripts/syncDb/model.js +60 -0
- package/scripts/syncDb/plan.js +160 -0
- package/scripts/syncDb/preflight.js +98 -0
- package/scripts/syncDb/report.js +177 -162
- package/scripts/syncDb/snapshot.js +118 -0
- package/tables/admin.json +51 -53
- package/tables/api.json +28 -39
- package/tables/dailyReport.json +85 -73
- package/tables/dict.json +27 -39
- package/tables/dictType.json +20 -34
- package/tables/emailLog.json +49 -47
- package/tables/errorReport.json +86 -60
- package/tables/file.json +47 -50
- package/tables/loginLog.json +68 -55
- package/tables/menu.json +20 -34
- package/tables/operateLog.json +53 -47
- package/tables/project.json +18 -30
- package/tables/role.json +33 -37
- package/utils/cors.js +37 -41
- package/utils/deepMerge.js +3 -1
- package/utils/error.js +1 -56
- package/utils/id.js +44 -0
- package/utils/is.js +6 -243
- package/utils/response.js +12 -1
- package/utils/scanFiles.js +13 -4
- package/utils/scanSources.js +58 -20
- package/utils/util.js +11 -163
- package/apis/_apis.js +0 -20
- package/apis/auth/sendSmsCode.js +0 -24
- package/configs/regexpAlias.json +0 -55
- package/lib/connect.js +0 -119
- package/lib/logger.js +0 -430
- package/lib/redisHelper.js +0 -399
- package/lib/validator.js +0 -274
- package/lib/xmlParse.js +0 -149
- package/plugins/config.js +0 -13
- package/plugins/cron.js +0 -115
- package/plugins/logger.js +0 -17
- package/plugins/tool.js +0 -131
- package/plugins/xmlParse.js +0 -10
- package/schemas/config.js +0 -67
- package/scripts/syncDb/context.js +0 -120
- package/scripts/syncDb/diff.js +0 -279
- package/scripts/syncDb/transform.js +0 -113
- package/sql/befly.sql +0 -271
- package/utils/email.js +0 -3
- package/utils/fieldClear.js +0 -22
- package/utils/formatValidationIssues.js +0 -28
- package/utils/getClientIp.js +0 -47
- package/utils/loggerUtils.js +0 -196
- package/utils/regexpUtil.js +0 -52
- /package/{lib → libs}/smtpText.js +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { updatePlanHash } from "./plan.js";
|
|
2
|
+
|
|
3
|
+
function quoteIdentifier(value) {
|
|
4
|
+
if (!/^[a-z][a-z0-9_]*$/.test(value)) throw new TypeError(`无效数据库标识符 ${value}`);
|
|
5
|
+
return `\`${value}\``;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function countNull(mysql, operation) {
|
|
9
|
+
const result = await mysql.execute(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(operation.table)} WHERE ${quoteIdentifier(operation.object)} IS NULL`);
|
|
10
|
+
return Number(result.data?.[0]?.count || 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function maxLength(mysql, operation) {
|
|
14
|
+
const result = await mysql.execute(`SELECT MAX(CHAR_LENGTH(${quoteIdentifier(operation.object)})) AS maxLength FROM ${quoteIdentifier(operation.table)}`);
|
|
15
|
+
return Number(result.data?.[0]?.maxLength || 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function rowCount(mysql, operation) {
|
|
19
|
+
const result = await mysql.execute(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(operation.table)}`);
|
|
20
|
+
return Number(result.data?.[0]?.count || 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function invalidDecimalCount(mysql, operation) {
|
|
24
|
+
const integerDigits = operation.after.precision - operation.after.scale;
|
|
25
|
+
const limit = 10 ** integerDigits;
|
|
26
|
+
const field = quoteIdentifier(operation.object);
|
|
27
|
+
const result = await mysql.execute(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(operation.table)} WHERE ABS(${field}) >= ? OR ${field} <> ROUND(${field}, ?)`, [limit, operation.after.scale]);
|
|
28
|
+
return Number(result.data?.[0]?.count || 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function outOfSignedBigintCount(mysql, operation) {
|
|
32
|
+
const result = await mysql.execute(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(operation.table)} WHERE ${quoteIdentifier(operation.object)} > 9223372036854775807`);
|
|
33
|
+
return Number(result.data?.[0]?.count || 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function oversizedMediumTextCount(mysql, operation) {
|
|
37
|
+
const result = await mysql.execute(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(operation.table)} WHERE OCTET_LENGTH(${quoteIdentifier(operation.object)}) > 16777215`);
|
|
38
|
+
return Number(result.data?.[0]?.count || 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function duplicateGroups(mysql, operation) {
|
|
42
|
+
const fields = operation.after.fields.map(quoteIdentifier);
|
|
43
|
+
const conditions = fields.map((field) => `${field} IS NOT NULL`).join(" AND ");
|
|
44
|
+
const result = await mysql.execute(`SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteIdentifier(operation.table)} WHERE ${conditions} GROUP BY ${fields.join(", ")} HAVING COUNT(*) > 1) AS duplicate_groups`);
|
|
45
|
+
return Number(result.data?.[0]?.count || 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runPreflight(mysql, plan) {
|
|
49
|
+
for (const operation of plan.operations) {
|
|
50
|
+
operation.preflight = null;
|
|
51
|
+
const checks = [];
|
|
52
|
+
if (operation.kind === "addColumn" && !operation.after.nullable && !operation.after.default.hasDefault) {
|
|
53
|
+
const count = await rowCount(mysql, operation);
|
|
54
|
+
checks.push({ type: "rowCount", count: count, blocked: count > 0, message: count > 0 ? `表中已有 ${count} 行,不能直接新增非空无默认值字段` : "表为空,可新增非空字段" });
|
|
55
|
+
}
|
|
56
|
+
if (operation.kind === "alterColumn" && operation.before.nullable && !operation.after.nullable) {
|
|
57
|
+
const count = await countNull(mysql, operation);
|
|
58
|
+
checks.push({ type: "nullCount", count: count, blocked: count > 0, message: count > 0 ? `存在 ${count} 行 NULL` : "不存在 NULL" });
|
|
59
|
+
}
|
|
60
|
+
if (operation.kind === "alterColumn" && operation.after.fieldType === "varchar" && ["varchar", "char"].includes(operation.before.fieldType) && operation.after.maxValue < operation.before.maxValue) {
|
|
61
|
+
const length = await maxLength(mysql, operation);
|
|
62
|
+
checks.push({ type: "maxLength", maxLength: length, blocked: length > operation.after.maxValue, message: `当前最大长度 ${length},目标长度 ${operation.after.maxValue}` });
|
|
63
|
+
}
|
|
64
|
+
if (operation.kind === "alterColumn" && operation.after.fieldType === "number" && operation.before.fieldType === "number" && (operation.after.precision < operation.before.precision || operation.after.scale < operation.before.scale)) {
|
|
65
|
+
const count = await invalidDecimalCount(mysql, operation);
|
|
66
|
+
checks.push({ type: "decimalRange", count: count, blocked: count > 0, message: count > 0 ? `存在 ${count} 行无法容纳到 DECIMAL(${operation.after.precision}, ${operation.after.scale})` : "现有数值均可容纳" });
|
|
67
|
+
}
|
|
68
|
+
if (operation.kind === "alterColumn" && operation.after.fieldType === "integer" && (operation.before.dbType || operation.before.fieldType) === "bigint" && operation.before.unsigned && !operation.after.unsigned) {
|
|
69
|
+
const count = await outOfSignedBigintCount(mysql, operation);
|
|
70
|
+
checks.push({ type: "signedBigintRange", count: count, blocked: count > 0, message: count > 0 ? `存在 ${count} 行超过 BIGINT SIGNED 最大值` : "现有整数均可转为 BIGINT SIGNED" });
|
|
71
|
+
}
|
|
72
|
+
if (operation.kind === "alterColumn" && operation.after.fieldType === "text" && ["json", "longtext"].includes(operation.before.dbType || operation.before.fieldType)) {
|
|
73
|
+
const count = await oversizedMediumTextCount(mysql, operation);
|
|
74
|
+
checks.push({ type: "mediumTextSize", count: count, blocked: count > 0, message: count > 0 ? `存在 ${count} 行超过 MEDIUMTEXT 最大容量` : "现有内容均可转为 MEDIUMTEXT" });
|
|
75
|
+
}
|
|
76
|
+
if ((operation.kind === "addIndex" || operation.kind === "replaceIndex") && operation.after.type === "unique") {
|
|
77
|
+
const count = await duplicateGroups(mysql, operation);
|
|
78
|
+
checks.push({ type: "duplicateGroups", count: count, blocked: count > 0, message: count > 0 ? `存在 ${count} 组重复值` : "不存在重复值" });
|
|
79
|
+
}
|
|
80
|
+
if (operation.kind === "dropIndex") checks.push({ type: "dropIndex", blocked: false, message: "索引未在 Tables 中声明,将被删除" });
|
|
81
|
+
if (checks.length > 0) operation.preflight = { type: checks.length === 1 ? checks[0].type : "multiple", blocked: checks.some((check) => check.blocked), message: checks.map((check) => check.message).join(";"), checks: checks };
|
|
82
|
+
|
|
83
|
+
if (operation.preflight?.blocked) {
|
|
84
|
+
plan.blocked.push({
|
|
85
|
+
id: `preflight:${operation.table}.${operation.object}`,
|
|
86
|
+
kind: "preflight",
|
|
87
|
+
table: operation.table,
|
|
88
|
+
object: operation.object,
|
|
89
|
+
before: operation.before,
|
|
90
|
+
after: operation.after,
|
|
91
|
+
reason: operation.preflight.message
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
plan.counts.blocked = plan.blocked.length;
|
|
97
|
+
return updatePlanHash(plan);
|
|
98
|
+
}
|
package/scripts/syncDb/report.js
CHANGED
|
@@ -1,195 +1,210 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
function formatValue(value) {
|
|
5
|
+
if (value === "") return '""';
|
|
6
|
+
if (value === null || value === undefined) return "无";
|
|
7
|
+
if (value && typeof value === "object") {
|
|
8
|
+
if (Object.hasOwn(value, "hasDefault")) return value.hasDefault ? formatValue(value.value) : "无";
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
}
|
|
11
|
+
return String(value);
|
|
12
|
+
}
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
const operationNames = {
|
|
15
|
+
createTable: "创建表",
|
|
16
|
+
alterTableComment: "修改表注释",
|
|
17
|
+
alterTableCollation: "修改排序规则",
|
|
18
|
+
addColumn: "新增字段",
|
|
19
|
+
alterColumn: "修改字段",
|
|
20
|
+
addIndex: "新增索引",
|
|
21
|
+
replaceIndex: "替换索引",
|
|
22
|
+
dropIndex: "删除索引"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const operationColors = {
|
|
26
|
+
createTable: "cyan",
|
|
27
|
+
alterTableComment: "limegreen",
|
|
28
|
+
alterTableCollation: "yellow",
|
|
29
|
+
addColumn: "limegreen",
|
|
30
|
+
alterColumn: "yellow",
|
|
31
|
+
addIndex: "limegreen",
|
|
32
|
+
replaceIndex: "yellow",
|
|
33
|
+
dropIndex: "red"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function colorText(text, color, enabled) {
|
|
37
|
+
if (!enabled) return text;
|
|
38
|
+
const ansi = Bun.color(color, "ansi");
|
|
39
|
+
return ansi ? `${ansi}${text}\x1b[0m` : text;
|
|
12
40
|
}
|
|
13
41
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const missingFieldCount = resolveSyncDbFieldCount(diff.missingFieldsByTable, diff.missingFieldCount);
|
|
19
|
-
const extraFieldGroups = Object.values(diff.extraFieldsByTable);
|
|
20
|
-
const extraFieldCount = resolveSyncDbFieldCount(diff.extraFieldsByTable, diff.extraFieldCount);
|
|
21
|
-
|
|
22
|
-
Logger.info("数据库与项目根目录表定义差异检查完成", {
|
|
23
|
-
missingTableCount: diff.missingTables.length,
|
|
24
|
-
missingFieldCount: missingFieldCount,
|
|
25
|
-
extraTableCount: diff.extraTables.length,
|
|
26
|
-
extraFieldCount: extraFieldCount,
|
|
27
|
-
missingTables: tableNames,
|
|
28
|
-
extraTables: extraTableFileNames
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
for (const item of diff.missingTables) {
|
|
32
|
-
Logger.info("发现缺失表", {
|
|
33
|
-
tableName: item.tableName,
|
|
34
|
-
tableFileName: item.tableFileName,
|
|
35
|
-
columnNames: item.columns.map((column) => column.columnName)
|
|
36
|
-
});
|
|
37
|
-
}
|
|
42
|
+
function targetFieldTypeText(field) {
|
|
43
|
+
if (!field || typeof field !== "object") return "-";
|
|
44
|
+
return field.fieldType === "integer" ? "BIGINT" : field.fieldType === "number" ? `DECIMAL(${field.precision},${field.scale})` : field.fieldType === "varchar" ? `VARCHAR(${field.maxValue})` : field.fieldType === "text" ? "MEDIUMTEXT" : "-";
|
|
45
|
+
}
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
}
|
|
47
|
+
function fieldTypeText(operation) {
|
|
48
|
+
if (!["addColumn", "alterColumn"].includes(operation.kind)) return "-";
|
|
49
|
+
return targetFieldTypeText(operation.after);
|
|
50
|
+
}
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
tableFileName: item.tableFileName,
|
|
50
|
-
fieldNames: item.fields.map((field) => field.fieldName)
|
|
51
|
-
});
|
|
52
|
-
}
|
|
52
|
+
function preflightResult(operation, colors) {
|
|
53
|
+
return colorText(operation.preflight?.blocked ? "否" : "是", operation.preflight?.blocked ? "red" : "limegreen", colors);
|
|
54
|
+
}
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
function buildSyncRows(plan, colors) {
|
|
57
|
+
const createdTables = new Set(plan.operations.filter((operation) => operation.kind === "createTable").map((operation) => operation.table));
|
|
58
|
+
const riskNames = { safe: "安全", warning: "警告", destructive: "破坏性" };
|
|
59
|
+
const riskColors = { safe: "limegreen", warning: "yellow", destructive: "red" };
|
|
60
|
+
const rows = plan.operations
|
|
61
|
+
.filter((operation) => operation.preflight?.blocked !== true && (operation.kind === "createTable" || !createdTables.has(operation.table)))
|
|
62
|
+
.map((operation) => ({
|
|
63
|
+
类型: colorText(operationNames[operation.kind], operationColors[operation.kind], colors),
|
|
64
|
+
风险: colorText(riskNames[operation.risk], riskColors[operation.risk], colors),
|
|
65
|
+
表: operation.table,
|
|
66
|
+
对象: operation.object || "-",
|
|
67
|
+
字段类型: fieldTypeText(operation),
|
|
68
|
+
变更: archiveChangeText(operation),
|
|
69
|
+
预检结果: preflightResult(operation, colors)
|
|
70
|
+
}));
|
|
71
|
+
for (const notice of plan.notices.filter((item) => !createdTables.has(item.table))) rows.push({ 类型: colorText("保留", "deepskyblue", colors), 风险: "-", 表: notice.table, 对象: notice.object || "-", 字段类型: "-", 变更: notice.message, 预检结果: "-" });
|
|
72
|
+
for (const blocked of plan.blocked.filter((item) => !createdTables.has(item.table))) {
|
|
73
|
+
const change = blocked.before !== undefined || blocked.after !== undefined ? `${formatValue(blocked.before)} -> ${formatValue(blocked.after)}` : blocked.reason;
|
|
74
|
+
rows.push({
|
|
75
|
+
类型: colorText("禁止同步", "orangered", colors),
|
|
76
|
+
风险: colorText("阻塞", "orangered", colors),
|
|
77
|
+
表: blocked.table,
|
|
78
|
+
对象: blocked.object || "-",
|
|
79
|
+
字段类型: targetFieldTypeText(blocked.target || blocked.after),
|
|
80
|
+
变更: change,
|
|
81
|
+
预检结果: blocked.kind === "preflight" ? colorText("否", "red", colors) : "-"
|
|
59
82
|
});
|
|
60
83
|
}
|
|
84
|
+
return rows;
|
|
61
85
|
}
|
|
62
86
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
const preflightNames = {
|
|
88
|
+
rowCount: "表数据行数",
|
|
89
|
+
nullCount: "NULL 数据",
|
|
90
|
+
maxLength: "字符长度",
|
|
91
|
+
decimalRange: "数值范围",
|
|
92
|
+
signedBigintRange: "BIGINT 范围",
|
|
93
|
+
mediumTextSize: "文本容量",
|
|
94
|
+
duplicateGroups: "唯一索引重复值",
|
|
95
|
+
dropIndex: "删除索引"
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function printPreflightFailures(plan) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const operation of plan.operations.filter((item) => item.preflight?.blocked)) {
|
|
101
|
+
const checks = operation.preflight.checks?.filter((check) => check.blocked) || [operation.preflight];
|
|
102
|
+
for (const check of checks) {
|
|
103
|
+
rows.push({
|
|
104
|
+
表: operation.table,
|
|
105
|
+
对象: operation.object || "-",
|
|
106
|
+
类型: operationNames[operation.kind],
|
|
107
|
+
检查项: preflightNames[check.type] || check.type,
|
|
108
|
+
详情: check.message
|
|
109
|
+
});
|
|
110
|
+
}
|
|
74
111
|
}
|
|
75
|
-
|
|
112
|
+
process.stdout.write(`[syncDb] 预检未通过:${rows.length} 项\n`);
|
|
113
|
+
if (rows.length > 0) process.stdout.write(Bun.inspect.table(rows));
|
|
114
|
+
return rows;
|
|
76
115
|
}
|
|
77
116
|
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
117
|
+
export function printSyncPlan(context) {
|
|
118
|
+
const { plan } = context;
|
|
119
|
+
process.stdout.write(`[syncDb] 模式:${context.apply ? "执行" : "预览"}\n`);
|
|
120
|
+
process.stdout.write(`[syncDb] 范围:${context.tableName || "全部表"}\n`);
|
|
121
|
+
process.stdout.write(`[syncDb] 数据库:${context.database},MySQL ${context.mysqlVersion}\n`);
|
|
122
|
+
const executableCount = plan.operations.filter((operation) => operation.preflight?.blocked !== true).length;
|
|
123
|
+
process.stdout.write(`[syncDb] 计划:可执行=${executableCount},blocked=${plan.blocked.length},保留提示=${plan.notices.length}\n`);
|
|
82
124
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
const rows = buildSyncRows(plan, true);
|
|
126
|
+
if (rows.length > 0) {
|
|
127
|
+
process.stdout.write("\n[syncDb] 同步明细\n");
|
|
128
|
+
process.stdout.write(Bun.inspect.table(rows));
|
|
86
129
|
}
|
|
87
130
|
|
|
88
|
-
|
|
131
|
+
if (!context.apply && executableCount > 0) process.stdout.write(`\n[syncDb] 预览完成。执行可同步内容:bun run syncDb${context.tableName ? ` ${context.tableName}` : ""} --yes\n`);
|
|
89
132
|
}
|
|
90
133
|
|
|
91
|
-
function
|
|
92
|
-
|
|
134
|
+
function escapeTableValue(value) {
|
|
135
|
+
return String(value ?? "")
|
|
136
|
+
.replaceAll("|", "\\|")
|
|
137
|
+
.replaceAll("\n", "<br>");
|
|
93
138
|
}
|
|
94
139
|
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
lines.push("");
|
|
140
|
+
function archiveChangeText(operation) {
|
|
141
|
+
if (operation.kind === "createTable") return "创建全新表";
|
|
142
|
+
if (operation.kind === "addColumn") return `新增字段:${operation.after.dbName},类型=${operation.after.fieldType},NULL=${operation.after.nullable ? "是" : "否"},默认值=${formatValue(operation.after.default)}`;
|
|
143
|
+
if (operation.kind === "addIndex") return `新增${operation.after.type === "unique" ? "唯一" : "普通"}索引:${operation.after.fields.join(", ")}`;
|
|
144
|
+
if (operation.kind === "replaceIndex") return `${formatValue(operation.before)} -> ${formatValue(operation.after)}`;
|
|
145
|
+
if (operation.kind === "dropIndex") return `删除未在 Tables 中声明的索引:${operation.object}`;
|
|
146
|
+
return operation.changes.map((change) => `${change.property}: ${formatValue(change.before)} -> ${formatValue(change.after)}`).join(";");
|
|
104
147
|
}
|
|
105
148
|
|
|
106
|
-
function
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
lines.push(
|
|
133
|
-
} else {
|
|
134
|
-
for (const [tableName, columns] of groupedDbColumns.entries()) {
|
|
135
|
-
appendSyncDbMarkdownRow(lines, [tableName, isNonEmptyString(columns[0]?.tableComment) ? String(columns[0].tableComment).trim() : "-", columns.length, existingTableMap.has(String(camelCase(tableName)).toLowerCase()) ? "是" : "否"]);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
lines.push("");
|
|
140
|
-
lines.push("## 全部差异信息");
|
|
141
|
-
lines.push("");
|
|
142
|
-
if (!hasDiff) {
|
|
143
|
-
lines.push("- 未发现差异");
|
|
149
|
+
function renderArchive(context) {
|
|
150
|
+
const lines = [
|
|
151
|
+
"# MySQL 表结构同步存档",
|
|
152
|
+
"",
|
|
153
|
+
"## 基本信息",
|
|
154
|
+
"",
|
|
155
|
+
"| 项目 | 内容 |",
|
|
156
|
+
"|---|---|",
|
|
157
|
+
`| 开始时间 | ${escapeTableValue(context.startedAt)} |`,
|
|
158
|
+
`| 完成时间 | ${escapeTableValue(context.finishedAt)} |`,
|
|
159
|
+
`| 同步范围 | ${escapeTableValue(context.tableName || "全部表")} |`,
|
|
160
|
+
`| 数据库地址 | ${escapeTableValue(context.hostname)} |`,
|
|
161
|
+
`| 端口 | ${escapeTableValue(context.port)} |`,
|
|
162
|
+
`| 用户名 | ${escapeTableValue(context.username)} |`,
|
|
163
|
+
`| 数据库名 | ${escapeTableValue(context.database)} |`,
|
|
164
|
+
`| MySQL | ${escapeTableValue(context.mysqlVersion)} |`,
|
|
165
|
+
"| 字符集 | utf8mb4 |",
|
|
166
|
+
"| 排序规则 | utf8mb4_0900_ai_ci |",
|
|
167
|
+
`| 状态 | ${escapeTableValue(context.status)} |`,
|
|
168
|
+
`| Plan Hash | ${escapeTableValue(context.plan.planHash)} |`,
|
|
169
|
+
""
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const rows = buildSyncRows(context.plan, false);
|
|
173
|
+
if (rows.length > 0) {
|
|
174
|
+
lines.push("## 同步明细", "", "| 类型 | 风险 | 表 | 对象 | 字段类型 | 变更 | 预检结果 |", "|---|---|---|---|---|---|---|");
|
|
175
|
+
for (const row of rows) lines.push(`| ${escapeTableValue(row.类型)} | ${escapeTableValue(row.风险)} | ${escapeTableValue(row.表)} | ${escapeTableValue(row.对象)} | ${escapeTableValue(row.字段类型)} | ${escapeTableValue(row.变更)} | ${escapeTableValue(row.预检结果)} |`);
|
|
144
176
|
lines.push("");
|
|
145
|
-
return lines.join("\n");
|
|
146
177
|
}
|
|
147
178
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
appendSyncDbDiffGroup(lines, tableInfo.tableName, `${tableInfo.tableFileName}.json`, groupRows);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const extraTable of diff.extraTables) {
|
|
166
|
-
appendSyncDbDiffGroup(lines, "-", `${extraTable.tableFileName}.json`, [["多余表", "-", "-", extraTable.filePath || "-", "-", "-", null, "-", null]]);
|
|
167
|
-
}
|
|
179
|
+
lines.push(
|
|
180
|
+
"## 最终验证",
|
|
181
|
+
"",
|
|
182
|
+
"| 项目 | 数量 |",
|
|
183
|
+
"|---|---:|",
|
|
184
|
+
`| 已执行 | ${(context.results || []).filter((result) => result.status === "success").length} |`,
|
|
185
|
+
`| 剩余可执行差异 | ${context.remainingPlan?.operations.length ?? context.plan.operations.length} |`,
|
|
186
|
+
`| 剩余阻塞差异 | ${context.remainingPlan?.blocked.length ?? context.plan.blocked.length} |`,
|
|
187
|
+
""
|
|
188
|
+
);
|
|
189
|
+
if (context.error) lines.push("## 错误", "", escapeTableValue(context.error), "");
|
|
190
|
+
return `${lines.join("\n")}\n`;
|
|
191
|
+
}
|
|
168
192
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
groupRows.push(["多余字段", "-", fieldItem.fieldName, fieldItem.fieldDef?.name, "-", "-", null, fieldItem.fieldDef?.input, fieldItem.fieldDef?.max]);
|
|
173
|
-
}
|
|
174
|
-
appendSyncDbDiffGroup(lines, tableInfo.tableName, `${tableInfo.tableFileName}.json`, groupRows);
|
|
175
|
-
}
|
|
193
|
+
function pad(value, length = 2) {
|
|
194
|
+
return String(value).padStart(length, "0");
|
|
195
|
+
}
|
|
176
196
|
|
|
177
|
-
|
|
178
|
-
return
|
|
197
|
+
function archiveFileName(date) {
|
|
198
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}.md`;
|
|
179
199
|
}
|
|
180
200
|
|
|
181
|
-
export async function
|
|
182
|
-
|
|
183
|
-
|
|
201
|
+
export async function writeSyncArchive(context, filePath = "") {
|
|
202
|
+
let targetPath = filePath;
|
|
203
|
+
if (!targetPath) {
|
|
204
|
+
const directory = resolve(process.cwd(), ".temp", "syncDb");
|
|
205
|
+
await mkdir(directory, { recursive: true });
|
|
206
|
+
targetPath = join(directory, archiveFileName(new Date()));
|
|
184
207
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const markdown = buildSyncDbReportMarkdown(diff, groupedDbColumns, existingTableMap);
|
|
188
|
-
await Bun.write(reportPath, markdown);
|
|
189
|
-
printSyncDbProcessLog(`差异报告已写入 ${reportPath}`);
|
|
190
|
-
Logger.info("差异报告写入完成", {
|
|
191
|
-
reportPath: reportPath
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
return reportPath;
|
|
208
|
+
await Bun.write(targetPath, renderArchive(context));
|
|
209
|
+
return targetPath;
|
|
195
210
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const VERSION_SQL = "SELECT VERSION() AS version, @@version_comment AS versionComment";
|
|
2
|
+
const TABLES_SQL = `
|
|
3
|
+
SELECT TABLE_NAME AS tableName, ENGINE AS engine, TABLE_COLLATION AS tableCollation, TABLE_COMMENT AS tableComment
|
|
4
|
+
FROM information_schema.TABLES
|
|
5
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
6
|
+
ORDER BY TABLE_NAME`;
|
|
7
|
+
const COLUMNS_SQL = `
|
|
8
|
+
SELECT TABLE_NAME AS tableName, COLUMN_NAME AS columnName, ORDINAL_POSITION AS ordinalPosition,
|
|
9
|
+
DATA_TYPE AS dataType, COLUMN_TYPE AS columnType, CHARACTER_MAXIMUM_LENGTH AS charLength,
|
|
10
|
+
NUMERIC_PRECISION AS numericPrecision, NUMERIC_SCALE AS numericScale, IS_NULLABLE AS isNullable,
|
|
11
|
+
COLUMN_DEFAULT AS columnDefault, EXTRA AS extra, COLUMN_KEY AS columnKey, COLUMN_COMMENT AS columnComment
|
|
12
|
+
FROM information_schema.COLUMNS
|
|
13
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
14
|
+
ORDER BY TABLE_NAME, ORDINAL_POSITION`;
|
|
15
|
+
const INDEXES_SQL = `
|
|
16
|
+
SELECT TABLE_NAME AS tableName, INDEX_NAME AS indexName, NON_UNIQUE AS nonUnique,
|
|
17
|
+
SEQ_IN_INDEX AS seqInIndex, COLUMN_NAME AS columnName, SUB_PART AS subPart,
|
|
18
|
+
INDEX_TYPE AS indexType, IS_VISIBLE AS isVisible
|
|
19
|
+
FROM information_schema.STATISTICS
|
|
20
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
21
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`;
|
|
22
|
+
|
|
23
|
+
export function assertMysql80(version, versionComment = "") {
|
|
24
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version));
|
|
25
|
+
const text = `${version} ${versionComment}`.toLowerCase();
|
|
26
|
+
const supported = match && Number(match[1]) >= 8;
|
|
27
|
+
if (!supported || /(mariadb|tidb|oceanbase)/.test(text)) throw new TypeError(`仅支持 Oracle MySQL 8.0 及以上版本,当前版本 ${version}`);
|
|
28
|
+
return `${match[1]}.${match[2]}.${match[3]}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeDefault(value) {
|
|
32
|
+
if (value === null || value === undefined) return { hasDefault: false, value: undefined };
|
|
33
|
+
return { hasDefault: true, value: value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getFieldType(row) {
|
|
37
|
+
if (row.dataType === "bigint") return "integer";
|
|
38
|
+
if (row.dataType === "decimal") return "number";
|
|
39
|
+
if (row.dataType === "varchar") return "varchar";
|
|
40
|
+
if (row.dataType === "mediumtext") return "text";
|
|
41
|
+
return row.dataType;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildDatabaseSnapshot(tableRows, columnRows, indexRows) {
|
|
45
|
+
const tables = Object.fromEntries(
|
|
46
|
+
tableRows.map((row) => [
|
|
47
|
+
row.tableName,
|
|
48
|
+
{
|
|
49
|
+
dbName: row.tableName,
|
|
50
|
+
name: row.tableComment || "",
|
|
51
|
+
engine: String(row.engine || "").toUpperCase(),
|
|
52
|
+
charset: String(row.tableCollation || "")
|
|
53
|
+
.split("_")
|
|
54
|
+
.slice(0, 1)
|
|
55
|
+
.join("_"),
|
|
56
|
+
collation: row.tableCollation || "",
|
|
57
|
+
columns: {},
|
|
58
|
+
indexes: {},
|
|
59
|
+
primary: null
|
|
60
|
+
}
|
|
61
|
+
])
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
for (const row of columnRows) {
|
|
65
|
+
const table = tables[row.tableName];
|
|
66
|
+
if (!table) continue;
|
|
67
|
+
const fieldType = getFieldType(row);
|
|
68
|
+
const nullable = row.isNullable === "YES";
|
|
69
|
+
const column = {
|
|
70
|
+
dbName: row.columnName,
|
|
71
|
+
name: row.columnComment || "",
|
|
72
|
+
fieldType: fieldType,
|
|
73
|
+
dbType: row.dataType,
|
|
74
|
+
nullable: nullable,
|
|
75
|
+
default: normalizeDefault(row.columnDefault),
|
|
76
|
+
maxValue: ["varchar", "char"].includes(fieldType) && row.charLength !== null ? Number(row.charLength) : undefined,
|
|
77
|
+
precision: fieldType === "number" && row.numericPrecision !== null ? Number(row.numericPrecision) : undefined,
|
|
78
|
+
scale: fieldType === "number" && row.numericScale !== null ? Number(row.numericScale) : undefined,
|
|
79
|
+
primary: row.columnKey === "PRI",
|
|
80
|
+
autoIncrement: String(row.extra || "")
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.includes("auto_increment"),
|
|
83
|
+
unsigned: /\bunsigned\b/i.test(String(row.columnType || ""))
|
|
84
|
+
};
|
|
85
|
+
table.columns[row.columnName] = column;
|
|
86
|
+
if (column.primary) table.primary = table.primary ? "__multiple__" : row.columnName;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const groupedIndexes = new Map();
|
|
90
|
+
for (const row of indexRows) {
|
|
91
|
+
if (row.indexName === "PRIMARY") continue;
|
|
92
|
+
const key = `${row.tableName}\0${row.indexName}`;
|
|
93
|
+
if (!groupedIndexes.has(key)) groupedIndexes.set(key, []);
|
|
94
|
+
groupedIndexes.get(key).push(row);
|
|
95
|
+
}
|
|
96
|
+
for (const rows of groupedIndexes.values()) {
|
|
97
|
+
const first = rows[0];
|
|
98
|
+
if (!tables[first.tableName]) continue;
|
|
99
|
+
tables[first.tableName].indexes[first.indexName] = {
|
|
100
|
+
name: first.indexName,
|
|
101
|
+
type: Number(first.nonUnique) === 0 ? "unique" : "index",
|
|
102
|
+
fields: rows.toSorted((left, right) => Number(left.seqInIndex) - Number(right.seqInIndex)).map((row) => row.columnName),
|
|
103
|
+
supported: rows.every((row) => row.columnName && row.subPart === null && String(row.indexType).toUpperCase() === "BTREE" && String(row.isVisible).toUpperCase() === "YES")
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return tables;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function readDatabaseSnapshot(mysql) {
|
|
110
|
+
const versionRows = (await mysql.execute(VERSION_SQL)).data;
|
|
111
|
+
const versionRow = versionRows[0] || {};
|
|
112
|
+
const mysqlVersion = assertMysql80(versionRow.version, versionRow.versionComment);
|
|
113
|
+
const [tableResult, columnResult, indexResult] = await Promise.all([mysql.execute(TABLES_SQL), mysql.execute(COLUMNS_SQL), mysql.execute(INDEXES_SQL)]);
|
|
114
|
+
return {
|
|
115
|
+
mysqlVersion: mysqlVersion,
|
|
116
|
+
tables: buildDatabaseSnapshot(tableResult.data || [], columnResult.data || [], indexResult.data || [])
|
|
117
|
+
};
|
|
118
|
+
}
|