159 lines
6.1 KiB
JavaScript
159 lines
6.1 KiB
JavaScript
#!/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+(?!(?:PRIMARY|UNIQUE|KEY|CONSTRAINT|CHECK|FOREIGN)\b)`?([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
|
|
})
|