#!/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 --right-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(',') } 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()}`) return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)) } function snapshot(config, schemas) { const selected = sqlStrings(schemas) const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) const tableRows = runMysql(config, `SELECT 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 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`) 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 })