Files
baiyue-firstleg/scripts/database-schema-diff.mjs
T

169 lines
8.5 KiB
JavaScript
Raw Normal View History

2026-09-20 18:51:10 +08:00
#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
const DEFAULT_SCHEMAS = [
'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse',
'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge',
'baiyue_finance', 'baiyue_lastleg', 'baiyue_lastlag_warehouse',
'baiyue_event_bridge_overseas',
]
function usage() {
console.log('用法:node scripts/database-schema-diff.mjs --left-env <env文件> --right-env <env文件> [--schemas schema1,schema2] [--json]')
console.log('env 键:CHONGCHONG_ENV、CHONGCHONG_DB_HOST、CHONGCHONG_DB_PORT、CHONGCHONG_DB_USERNAME、CHONGCHONG_DB_PASSWORD')
console.log('只查询 information_schema;不读取业务数据,不执行 DDL/DML。差异退出码为 1,执行错误退出码为 2。')
}
function argument(argv, name) {
const index = argv.indexOf(name)
return index >= 0 ? argv[index + 1] : undefined
}
function parseArguments(argv) {
if (argv.includes('--help') || argv.includes('-h')) return { help: true }
const leftEnv = argument(argv, '--left-env')
const rightEnv = argument(argv, '--right-env')
if (!leftEnv || !rightEnv) throw new Error('必须同时指定 --left-env 和 --right-env')
const schemasValue = argument(argv, '--schemas')
const schemas = schemasValue ? schemasValue.split(',').map((item) => item.trim()).filter(Boolean) : DEFAULT_SCHEMAS
if (!schemas.length || schemas.some((schema) => !/^[a-z][a-z0-9_]*$/i.test(schema))) throw new Error('--schemas 包含无效 schema 名称')
return { leftEnv: path.resolve(leftEnv), rightEnv: path.resolve(rightEnv), schemas: [...new Set(schemas)], json: argv.includes('--json') }
}
function unquote(value) {
const trimmed = value.trim()
if (trimmed.length >= 2 && trimmed[0] === trimmed.at(-1) && ['"', "'"].includes(trimmed[0])) return trimmed.slice(1, -1)
return trimmed
}
async function readConfig(file) {
const values = {}
const source = await readFile(file, 'utf8')
for (const line of source.split(/\r?\n/)) {
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/)
if (match) values[match[1]] = unquote(match[2])
}
const required = ['CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD']
const missing = required.filter((key) => !values[key])
if (missing.length) throw new Error(`${path.basename(file)} 缺少:${missing.join(', ')}`)
return { ...values, label: values.CHONGCHONG_ENV || path.basename(file) }
}
function sqlStrings(values) {
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',')
}
2026-09-30 15:16:39 +08:00
export function parseMetadataRows(output) {
return output.trim().split(/\r?\n/).filter(Boolean).map((line) => {
if (!/^(?:[0-9a-f]{2})+$/i.test(line)) throw new Error('数据库元数据输出不是有效 HEX JSON')
return JSON.parse(Buffer.from(line, 'hex').toString('utf8'))
})
}
2026-09-20 18:51:10 +08:00
function runMysql(config, sql) {
const executable = process.env.MYSQL_BIN || 'mysql'
const result = spawnSync(executable, [
'--protocol=TCP', '-h', config.CHONGCHONG_DB_HOST, '-P', config.CHONGCHONG_DB_PORT,
'-u', config.CHONGCHONG_DB_USERNAME, '--batch', '--raw', '--skip-column-names',
'--default-character-set=utf8mb4', '-e', sql,
], {
encoding: 'utf8',
env: { ...process.env, MYSQL_PWD: config.CHONGCHONG_DB_PASSWORD },
maxBuffer: 32 * 1024 * 1024,
})
if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`)
if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`)
2026-09-30 15:16:39 +08:00
return parseMetadataRows(result.stdout)
2026-09-20 18:51:10 +08:00
}
function snapshot(config, schemas) {
const selected = sqlStrings(schemas)
2026-09-30 15:16:39 +08:00
const schemaRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', SCHEMA_NAME)) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`)
const tableRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, ''))) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`)
const columnRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION)) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`)
2026-09-20 18:51:10 +08:00
return { schemas: schemaRows, tables: tableRows, columns: columnRows }
}
function keyed(rows, keyOf, normalized) {
return new Map(rows.map((row) => [keyOf(row), normalized(row)]))
}
function compareMaps(left, right) {
const leftOnly = [...left.keys()].filter((key) => !right.has(key)).sort()
const rightOnly = [...right.keys()].filter((key) => !left.has(key)).sort()
const changed = [...left.keys()].filter((key) => right.has(key) && JSON.stringify(left.get(key)) !== JSON.stringify(right.get(key))).sort()
.map((key) => ({ key, left: left.get(key), right: right.get(key) }))
return { leftOnly, rightOnly, changed }
}
export function compareSnapshots(left, right) {
const schemaKey = (row) => row.schema
const tableKey = (row) => `${row.schema}.${row.table}`
const columnKey = (row) => `${row.schema}.${row.table}.${row.column}`
const schemas = compareMaps(keyed(left.schemas, schemaKey, () => true), keyed(right.schemas, schemaKey, () => true))
const tables = compareMaps(
keyed(left.tables, tableKey, ({ type, engine, collation }) => ({ type, engine, collation })),
keyed(right.tables, tableKey, ({ type, engine, collation }) => ({ type, engine, collation })),
)
const normalizeColumn = ({ type, nullable, defaultValue, extra, charset, collation, generation }) => (
{ type, nullable, defaultValue, extra, charset, collation, generation }
)
const columns = compareMaps(keyed(left.columns, columnKey, normalizeColumn), keyed(right.columns, columnKey, normalizeColumn))
return { schemas, tables, columns }
}
function count(diff) {
return diff.leftOnly.length + diff.rightOnly.length + diff.changed.length
}
function printSection(title, diff, leftLabel, rightLabel) {
if (!count(diff)) return
console.log(`\n${title}`)
for (const key of diff.leftOnly) console.log(` 仅 ${leftLabel}: ${key}`)
for (const key of diff.rightOnly) console.log(` 仅 ${rightLabel}: ${key}`)
for (const item of diff.changed) {
console.log(` 定义不同: ${item.key}`)
console.log(` ${leftLabel}: ${JSON.stringify(item.left)}`)
console.log(` ${rightLabel}: ${JSON.stringify(item.right)}`)
}
}
async function main() {
const options = parseArguments(process.argv.slice(2))
if (options.help) return usage()
const [leftConfig, rightConfig] = await Promise.all([readConfig(options.leftEnv), readConfig(options.rightEnv)])
if (leftConfig.label === rightConfig.label) throw new Error(`两侧环境标签相同:${leftConfig.label}`)
const [left, right] = [snapshot(leftConfig, options.schemas), snapshot(rightConfig, options.schemas)]
const differences = compareSnapshots(left, right)
const result = {
left: leftConfig.label,
right: rightConfig.label,
checkedSchemas: options.schemas,
summary: {
schemas: count(differences.schemas),
tables: count(differences.tables),
columns: count(differences.columns),
},
differences,
}
if (options.json) console.log(JSON.stringify(result, null, 2))
else {
console.log(`数据库结构比较:${result.left} <-> ${result.right}`)
console.log(`检查 schema=${options.schemas.length};schema 差异=${result.summary.schemas},表差异=${result.summary.tables},字段差异=${result.summary.columns}`)
printSection('SCHEMA 差异', differences.schemas, result.left, result.right)
printSection('表差异', differences.tables, result.left, result.right)
printSection('字段差异', differences.columns, result.left, result.right)
}
if (Object.values(result.summary).some((value) => value > 0)) process.exitCode = 1
}
const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname.replace(/^\/(?:[A-Za-z]:)/, (value) => value.slice(1)))
if (isEntryPoint) main().catch((error) => {
console.error(`DATABASE_SCHEMA_DIFF_ERROR: ${error.message}`)
process.exitCode = 2
})