Files

159 lines
6.1 KiB
JavaScript
Raw Permalink Normal View History

2026-09-20 14:43:26 +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'
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) {
2026-09-20 18:51:10 +08:00
const field = line.match(/^\s+(?!(?:PRIMARY|UNIQUE|KEY|CONSTRAINT|CHECK|FOREIGN)\b)`?([a-z][a-z0-9_]*)`?\s+/i)
2026-09-20 14:43:26 +08:00
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
})