This commit is contained in:
2026-09-20 14:43:26 +08:00
parent 398f4e6983
commit a7d458cddd
11 changed files with 356 additions and 23 deletions
+1
View File
@@ -126,6 +126,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 |
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway |
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
| Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 |
| 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 |
| 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 |
| 本地质量 | `java-quality-summary.mjs` | 聚合 Maven 拓扑、OpenAPI、JaCoCo 和 SpotBugs 已有报告,不启动服务 |
+2 -2
View File
@@ -267,10 +267,10 @@ $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/re
Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义'
Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口'
Assert-Contains $firstlegAgents '服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数' 'Firstleg 同柜建组边界'
Assert-Contains $firstlegAgents '服务端允许待审核、待测量/测量中、待分柜及装柜中订单' 'Firstleg 同柜建组边界'
Assert-Contains $warehouseAgents 'scripts/warehouse-public-master-data-contract.test.mjs' 'Warehouse 局部脚本入口'
Assert-Contains $coloadOpenApi '允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致' '同柜资格 OpenAPI'
Assert-Contains $coloadOpenApi '服务端解析每个订单全部 WAIT_SPLIT' '同柜订单展开 OpenAPI'
Assert-Contains $coloadOpenApi '待分柜或装柜中的全部有效入仓片段' '同柜订单展开 OpenAPI'
Assert-Contains $rootAgents '用户可见页面、提示和错误不得展示或以内部数据库 ID 作为业务编号回退' '根业务编号展示边界'
Assert-Contains $adminAgents '/loading-plans/{planId}/lifecycle' 'Admin 装柜清单生命周期入口'
Assert-Contains $adminAgents '合柜报关 / 同柜不可拆组' 'Admin 合柜报关别名入口'
+158
View File
@@ -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
})