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 } }