#!/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' import { fileURLToPath } from 'node:url' const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = path.resolve(SCRIPT_DIR, '..') const SQL_ROOT = path.join(REPO_ROOT, 'deploy', 'sql') const MYSQL_CANDIDATES = [process.env.MYSQL_BIN, 'mysql'].filter(Boolean) const REGIONS = { CN: { sql: path.join(SQL_ROOT, '头程数据库统一增量-MySQL8.sql'), schemas: [ 'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', 'baiyue_finance', ], }, OVERSEAS: { sql: path.join(SQL_ROOT, '海外区域统一增量-MySQL8.sql'), schemas: ['baiyue_lastleg', 'baiyue_lastlag_warehouse', 'baiyue_event_bridge_overseas'], }, } function usage() { console.log('用法:node scripts/database-schema-contract.mjs --region CN|OVERSEAS|BOTH') console.log('必需环境变量:SCHEMA_CHECK_HOST、SCHEMA_CHECK_PORT、SCHEMA_CHECK_USERNAME、SCHEMA_CHECK_PASSWORD') console.log('可选环境变量:MYSQL_BIN;默认只读查询 information_schema,不执行 SQL') } function parseRegion(argv) { const index = argv.indexOf('--region') const region = index >= 0 ? argv[index + 1] : 'BOTH' if (region === 'BOTH') return ['CN', 'OVERSEAS'] if (!REGIONS[region]) throw new Error(`不支持的区域:${region}`) return [region] } function config() { const keys = ['SCHEMA_CHECK_HOST', 'SCHEMA_CHECK_PORT', 'SCHEMA_CHECK_USERNAME', 'SCHEMA_CHECK_PASSWORD'] const missing = keys.filter((key) => !process.env[key]) if (missing.length) throw new Error(`缺少只读数据库环境变量:${missing.join(', ')}`) return Object.fromEntries(keys.map((key) => [key, process.env[key]])) } function parseExpected(sql) { const expected = new Map() let currentSchema = '' const lines = sql.split(/\r?\n/) for (let index = 0; index < lines.length; index += 1) { const use = lines[index].match(/^USE\s+`?([a-z][a-z0-9_]*)`?\s*;/i) if (use) { currentSchema = use[1] continue } const start = lines[index].match(/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(?:`?([a-z][a-z0-9_]*)`?\.)?`?([a-z][a-z0-9_]*)`?\s*\(/i) if (!start) continue const body = [] index += 1 while (index < lines.length && !/^\) ENGINE=/i.test(lines[index])) { body.push(lines[index]) index += 1 } const schema = start[1] || currentSchema const table = start[2] const columns = new Set() for (const line of body) { const field = line.match(/^\s+`([a-z][a-z0-9_]*)`\s+/i) if (field) columns.add(field[1]) } if (!columns.size) continue const key = schema ? `${schema}.${table}` : table expected.set(key, columns) } return expected } function mysql(cfg, database, sql) { let lastError = '' for (const executable of MYSQL_CANDIDATES) { const result = spawnSync(executable, [ '--protocol=TCP', '-h', cfg.SCHEMA_CHECK_HOST, '-P', cfg.SCHEMA_CHECK_PORT, '-u', cfg.SCHEMA_CHECK_USERNAME, '--database', database, '--batch', '--skip-column-names', '--default-character-set=utf8mb4', ], { input: sql, encoding: 'utf8', env: { ...process.env, MYSQL_PWD: cfg.SCHEMA_CHECK_PASSWORD } }) if (result.error?.code === 'ENOENT') { lastError = result.error.message continue } if (result.status !== 0) throw new Error((result.stderr || result.stdout || 'mysql 查询失败').trim()) return result.stdout } throw new Error(`找不到 mysql 客户端:${lastError}`) } function actualSchema(cfg, schema) { const sql = 'SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = DATABASE() ORDER BY table_name, ordinal_position;' const output = mysql(cfg, schema, sql) const actual = new Map() for (const line of output.trim().split(/\r?\n/).filter(Boolean)) { const [table, column] = line.split('\t') if (!table || !column) continue if (!actual.has(table)) actual.set(table, new Set()) actual.get(table).add(column) } return actual } function compare(expected, actual) { const missingTables = [] const missingColumns = [] for (const [table, columns] of expected) { const current = actual.get(table) if (!current) { missingTables.push(table) continue } for (const column of columns) if (!current.has(column)) missingColumns.push(`${table}.${column}`) } return { missingTables, missingColumns } } async function main() { const argv = process.argv.slice(2) if (argv.includes('--help') || argv.includes('-h')) return usage() const cfg = config() const regions = parseRegion(argv) const failures = [] for (const region of regions) { const definition = REGIONS[region] const sql = await readFile(definition.sql, 'utf8') const expectedAll = parseExpected(sql) for (const schema of definition.schemas) { const expected = new Map([...expectedAll].filter(([key]) => key.startsWith(`${schema}.`)).map(([key, columns]) => [key.slice(schema.length + 1), columns])) const actual = actualSchema(cfg, schema) const result = compare(expected, actual) console.log(`${region} ${schema}: expected_tables=${expected.size} actual_tables=${actual.size} missing_tables=${result.missingTables.length} missing_columns=${result.missingColumns.length}`) if (result.missingTables.length) console.log(` missing_tables=${result.missingTables.join(',')}`) if (result.missingColumns.length) console.log(` missing_columns=${result.missingColumns.join(',')}`) failures.push(...result.missingTables.map((item) => `${region}/${schema}/${item}`), ...result.missingColumns.map((item) => `${region}/${schema}/${item}`)) } } if (failures.length) { console.error(`DATABASE_SCHEMA_CONTRACT_FAIL missing=${failures.length}`) process.exitCode = 1 return } console.log(`DATABASE_SCHEMA_CONTRACT_PASS regions=${regions.join(',')}`) } main().catch((error) => { console.error(`DATABASE_SCHEMA_CONTRACT_ERROR: ${error.message}`) process.exitCode = 1 })