Files
baiyue-firstleg/scripts/test-env-business-reset-plan.mjs
T

201 lines
12 KiB
JavaScript
Raw Normal View History

2026-10-21 21:30:24 +08:00
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { tableDefinitions } from './sql-baseline-contract-helpers.mjs'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const policyPath = new URL('./test-env-business-reset-policy.json', import.meta.url)
const baselinePaths = [
'deploy/sql/头程数据库统一增量-MySQL8.sql',
'deploy/sql/海外区域统一增量-MySQL8.sql',
]
const actions = new Set(['KEEP', 'CLEAR_CANDIDATE', 'RESET_REVIEW', 'REVIEW'])
/** 对基线正文取指纹;统一换行,避免不同工作区换行规则造成误报。 */
export function fingerprint(text) {
return createHash('sha256').update(text.replaceAll('\r\n', '\n')).digest('hex')
}
/** 只接受当前基线使用的简单标识符,生成的 SQL 不接受外部表达式。 */
function identifier(value) {
if (typeof value !== 'string' || !/^[a-z][a-z0-9_]*$/.test(value)) {
throw new Error('表或 schema 标识符非法')
}
return value
}
/** 从空库建表基线提取范围和约束,不执行初始化 SQL。 */
export function readCatalog(sources) {
const tables = new Map()
for (const source of sources) {
const definitions = tableDefinitions(source.sql)
const declarationCount = [...source.sql.matchAll(/^CREATE\s+TABLE\b/gim)].length
if (definitions.size !== declarationCount) throw new Error(`未能完整解析建表定义:${source.path}`)
for (const [key, definition] of definitions) {
if (tables.has(key)) throw new Error(`重复表:${key}`)
const [schema, name] = key.split('.').map(identifier)
const references = [...definition.matchAll(/\bREFERENCES\s+(?:(\w+)\.)?(\w+)\s*\(/gi)]
.map(match => `${match[1] ?? schema}.${match[2]}`)
tables.set(key, {
key, schema, name, source: source.path,
definitionSha256: fingerprint(definition),
description: definition.match(/\) ENGINE=[^\n]*COMMENT='([^']*)'/)?.[1] ?? '',
references: [...new Set(references)], triggers: [],
})
}
let schema
for (const match of source.sql.matchAll(/^USE (\w+);|^CREATE TRIGGER (\w+) (BEFORE|AFTER) (INSERT|UPDATE|DELETE) ON (\w+)/gm)) {
if (match[1]) schema = match[1]
else {
const table = tables.get(`${schema}.${match[5]}`)
if (!table) throw new Error(`触发器对应表不在清单中:${match[2]}`)
table.triggers.push({ name: match[2], timing: match[3], event: match[4] })
}
}
}
return [...tables.values()].sort((a, b) => a.key.localeCompare(b.key))
}
/** 将已逐表登记的建议与当前基线比对;未知表始终保持待审,不自动加入清理。 */
export function buildPlan(sources, policy, inventory = null) {
if (policy.version !== 1 || policy.environment !== 'TEST' || !Array.isArray(policy.groups)) {
throw new Error('清理策略必须为版本 1 的 TEST 策略')
}
const registered = new Map()
for (const group of policy.groups) {
if (!actions.has(group.action) || !group.reason || !Array.isArray(group.tables)) throw new Error('策略分组格式非法')
for (const key of group.tables) {
if (registered.has(key)) throw new Error(`策略重复登记:${key}`)
registered.set(key, group)
}
}
const blockers = []
const sourcesStatus = sources.map(source => {
const sha256 = fingerprint(source.sql)
if (policy.sourceSha256?.[source.path] !== sha256) blockers.push({ code: 'BASELINE_CHANGED', source: source.path })
return { path: source.path, sha256 }
})
const tables = readCatalog(sources).map(table => {
const group = registered.get(table.key)
if (!group) blockers.push({ code: 'UNCLASSIFIED_TABLE', table: table.key })
return { ...table, action: group?.action ?? 'REVIEW', reason: group?.reason ?? '新表未分类,禁止自动清理',
estimatedRows: null }
})
const byKey = new Map(tables.map(table => [table.key, table]))
for (const key of registered.keys()) {
if (!byKey.has(key)) blockers.push({ code: 'STALE_POLICY_TABLE', table: key })
}
const schemas = [...new Set(tables.map(table => table.schema))].sort()
const inventoryProblems = []
if (inventory === null) blockers.push({ code: 'LIVE_INVENTORY_REQUIRED' })
else {
if (!Array.isArray(inventory)) throw new Error('结构清单应为 inspection.sql 第一条查询导出的 JSON 数组')
const seen = new Set()
for (const row of inventory) {
const key = `${identifier(row.schema)}.${identifier(row.table)}`
if (seen.has(key)) throw new Error(`实际结构清单重复表:${key}`)
seen.add(key)
if (!schemas.includes(row.schema)) throw new Error('结构清单包含目标范围外的 schema')
if (!byKey.has(key)) inventoryProblems.push({ code: 'UNKNOWN_LIVE_TABLE', table: key })
else if (row.type !== 'BASE TABLE') inventoryProblems.push({ code: 'UNEXPECTED_TABLE_TYPE', table: key })
else {
// INFORMATION_SCHEMA.TABLE_ROWS 只是估算;不得将其作为删除数量或精确验收结果。
byKey.get(key).estimatedRows = row.estimatedRows ?? null
}
}
for (const table of tables) if (!seen.has(table.key)) inventoryProblems.push({ code: 'MISSING_LIVE_TABLE', table: table.key })
blockers.push(...inventoryProblems)
}
const retainedReferences = tables.flatMap(table => table.action === 'CLEAR_CANDIDATE' ? [] :
table.references.filter(key => byKey.get(key)?.action === 'CLEAR_CANDIDATE')
.map(parent => ({ child: table.key, parent })))
for (const relation of retainedReferences) blockers.push({ code: 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT', ...relation })
const summary = { total: tables.length, KEEP: 0, CLEAR_CANDIDATE: 0, RESET_REVIEW: 0, REVIEW: 0 }
for (const table of tables) summary[table.action]++
return {
version: 1, environment: 'TEST', status: 'REVIEW_REQUIRED', executionSupported: false,
// 结构清单的表名吻合不证明字段、外键、触发器、运行版本或目标环境已经核对通过。
confirmedScope: policy.confirmedScope,
summary, sources: sourcesStatus, schemas, blockers, tables,
canGenerateCountQueries: inventory !== null && inventoryProblems.length === 0,
pendingReview: policy.pendingReview,
limitations: [
'仅生成候选清单和只读查询;不连接数据库,不生成或执行删除、重置、停机及文件清理命令。',
'基线不是实际部署证明。必须核对实际字段、外键、索引、触发器、当前服务实现及混合表逐行处理条件。',
'保留基础资料、自营数据、独立审计和文件元数据;业务过程历史的保留边界与外键需审阅。',
'执行前须确定维护窗口、停止全部相关写入者及后台重试、备份恢复方案,并对精确目标和操作取得授权。',
'Redis、消息重投、搜索索引和外部采集不在此 SQL 清单内,不可由数据库清理成功推定已处理。',
'TABLE_ROWS 为估算;counts.sql 仅在导入完整且表名吻合的清单后提供,结果仍需在静默窗口重新核对。',
],
}
}
/** 生成只读结构盘点 SQL;不输出业务行、密码、文件路径或连接地址。 */
export function inspectionSql(schemas) {
const scope = schemas.map(schema => `'${identifier(schema)}'`).join(', ')
return `-- 仅在操作者明确选择的 TEST 连接执行;本文件只有只读查询。\n`
+ `-- 将第一条查询的 inventory_json 字段原样保存为 JSON 文件,可交给 --inventory。\n`
+ `-- 只读账号需要能看见全部目标表;缺少权限可能表现为表缺失,必须人工核实。\n`
+ `SELECT JSON_ARRAYAGG(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'estimatedRows', TABLE_ROWS)) AS inventory_json\nFROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${scope});\n\n`
+ `SELECT TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA\nFROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${scope}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION;\n\n`
+ `SELECT TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME\nFROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME IS NOT NULL AND (TABLE_SCHEMA IN (${scope}) OR REFERENCED_TABLE_SCHEMA IN (${scope})) ORDER BY TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION;\n\n`
+ `SELECT TRIGGER_SCHEMA, TRIGGER_NAME, EVENT_OBJECT_TABLE, EVENT_MANIPULATION, ACTION_TIMING, ACTION_STATEMENT\nFROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA IN (${scope}) ORDER BY TRIGGER_SCHEMA, EVENT_OBJECT_TABLE, TRIGGER_NAME;\n\n`
+ `SELECT TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME\nFROM information_schema.STATISTICS WHERE TABLE_SCHEMA IN (${scope}) ORDER BY TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;\n`
}
/** 只对已盘点且表名完整吻合的范围生成精确计数查询;仍不执行。 */
export function countSql(plan) {
if (!plan.canGenerateCountQueries) throw new Error('须先导入完整且表名吻合的实际结构清单')
return '-- 只读精确计数;大表可能耗时。须在维护窗口核对,禁止遇错继续。\n'
+ plan.tables.map(table => `SELECT '${table.key}' AS table_key, COUNT(*) AS exact_rows FROM \`${identifier(table.schema)}\`.\`${identifier(table.name)}\`;`).join('\n') + '\n'
}
/** 只解析离线参数,不提供 execute 模式或数据库凭据参数。 */
function parseArgs(args) {
const options = {}
for (let index = 0; index < args.length; index++) {
const name = args[index]
if (name === '--help') { options.help = true; continue }
if (!['--out-dir', '--inventory'].includes(name) || options[name] || !args[index + 1] || args[index + 1].startsWith('--')) {
throw new Error(`未知、重复或缺少值的参数:${name}`)
}
options[name] = args[++index]
}
return options
}
/** 输出到新的报告目录,独占创建,防止覆盖既有证据或源码。 */
export function writeReport(outputDirectory, plan) {
const reportsRoot = path.join(repositoryRoot, 'reports')
const target = path.resolve(repositoryRoot, outputDirectory)
if (path.dirname(target) !== reportsRoot || !/^business-reset-[a-zA-Z0-9_-]+$/.test(path.basename(target))) {
throw new Error('报告目录必须为 reports/business-reset-<本次唯一标识>,且直接位于 reports 下')
}
mkdirSync(reportsRoot, { recursive: true })
mkdirSync(target)
writeFileSync(path.join(target, 'plan.json'), `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx' })
writeFileSync(path.join(target, 'inspection.sql'), inspectionSql(plan.schemas), { flag: 'wx' })
if (plan.canGenerateCountQueries) writeFileSync(path.join(target, 'counts.sql'), countSql(plan), { flag: 'wx' })
return target
}
/** 命令行入口仅做离线准备;退出 0 只表示报告生成成功,绝不表示允许清库。 */
function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log('离线 TEST 清理预览:node scripts/test-env-business-reset-plan.mjs [--out-dir reports/business-reset-<唯一标识>] [--inventory <结构清单.json>]\n无执行模式;不联网;不需要凭据。')
return
}
const sources = baselinePaths.map(name => ({ path: name, sql: readFileSync(path.join(repositoryRoot, name), 'utf8') }))
const policy = JSON.parse(readFileSync(policyPath, 'utf8'))
const inventory = options['--inventory'] ? JSON.parse(readFileSync(options['--inventory'], 'utf8')) : null
const plan = buildPlan(sources, policy, inventory)
const output = options['--out-dir'] ? writeReport(options['--out-dir'], plan) : null
console.log(JSON.stringify({ status: plan.status, executionSupported: false, summary: plan.summary, blockers: plan.blockers, output }, null, 2))
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { main() } catch (error) { console.error(error.message); process.exitCode = 1 }
}