优化
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!/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
|
||||
})
|
||||
Reference in New Issue
Block a user