import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const DEVELOPMENT_DATABASE = 'a_share_analysis_dev'; const REQUIRED_CHARSET = 'utf8mb4'; const REQUIRED_COLLATION = 'utf8mb4_general_ci'; const BASELINE_FORMAT_VERSION = 2; const LEGACY_BASELINE_FORMAT_VERSION = 1; const TOOL_VERSION = '2.0.0'; const LEGACY_TOOL_VERSION = '1.0.0'; const VERSION_PATTERN = /^v\d+\.\d+\.\d+$/; const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '112.192.20.11']); const OBJECT_KEYS = Object.freeze(['tables', 'views', 'triggers', 'routines', 'events']); const CAPTURED_FILES = Object.freeze(['checksum.sha256', 'manifest.yaml', 'schema.sql']); const CAPTURED_EMPTY_FILES = Object.freeze(['manifest.yaml']); const NOT_APPLICABLE_FILES = Object.freeze(['manifest.yaml']); function requireExactValue(actual, expected, message) { if (actual !== expected) { throw new Error(`${message},要求 ${expected},实际 ${actual || '(空)'}`); } } function isEnabled(value) { return value === true || value === 'true'; } /** * Schema 工具拥有比普通应用启动更窄的权限边界:环境名、数据库名和主机必须 * 同时命中代码内固定值。远程开发库不能通过 env 或命令参数动态扩展白名单, * 避免把一次临时连接授权意外变成生产数据库访问能力。 */ export function assertSafeDevelopmentConfiguration(config) { requireExactValue(config.APP_ENV, 'development', 'Schema 基线只允许 development 环境'); requireExactValue( config.MYSQL_DATABASE, DEVELOPMENT_DATABASE, 'Schema 基线只允许固定开发库 a_share_analysis_dev', ); requireExactValue(config.MYSQL_CHARSET, REQUIRED_CHARSET, '数据库字符集不符合要求'); requireExactValue( config.MYSQL_COLLATION, REQUIRED_COLLATION, '数据库排序规则不符合要求', ); if (isEnabled(config.MYSQL_ENABLED)) { if (!DEVELOPMENT_HOSTS.has(config.MYSQL_HOST)) { throw new Error( `Schema 基线只允许固定开发数据库主机白名单,实际 ${config.MYSQL_HOST || '(空)'}`, ); } for (const key of ['MYSQL_HOST', 'MYSQL_PORT', 'MYSQL_USERNAME', 'MYSQL_PASSWORD']) { if (!String(config[key] ?? '').trim()) { throw new Error(`MySQL 已启用,${key} 不能为空`); } } } else if (config.MYSQL_ENABLED !== false && config.MYSQL_ENABLED !== 'false') { throw new Error('MYSQL_ENABLED 必须显式为 true 或 false'); } return Object.freeze({ ...config, MYSQL_ENABLED: isEnabled(config.MYSQL_ENABLED) }); } /** * mysql、mysqldump 与服务端统一接受完整 5.7.x;字符集和排序规则仍按精确值 * 校验,避免放宽补丁版本时意外放宽数据库结构兼容边界。 */ export function assertMysql57Metadata(metadata) { if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqlClientVersion ?? '')) { throw new Error(`mysql 客户端必须为 5.7.x,实际 ${metadata.mysqlClientVersion || '(空)'}`); } if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqldumpClientVersion ?? '')) { throw new Error( `mysqldump 客户端必须为 5.7.x,实际 ${metadata.mysqldumpClientVersion || '(空)'}`, ); } if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) { throw new Error(`MySQL 服务端必须为 5.7.x,实际 ${metadata.serverVersion || '(空)'}`); } requireExactValue(metadata.databaseCharset, REQUIRED_CHARSET, '开发数据库字符集不符合要求'); requireExactValue( metadata.databaseCollation, REQUIRED_COLLATION, '开发数据库排序规则不符合要求', ); return metadata; } export function buildMysqldumpArguments(config) { assertSafeDevelopmentConfiguration(config); return [ `--host=${config.MYSQL_HOST}`, `--port=${config.MYSQL_PORT}`, `--user=${config.MYSQL_USERNAME}`, `--default-character-set=${REQUIRED_CHARSET}`, '--no-data', '--routines', '--triggers', '--events', '--single-transaction', '--skip-lock-tables', '--skip-comments', DEVELOPMENT_DATABASE, ]; } function buildMysqlMetadataArguments(config) { return [ `--host=${config.MYSQL_HOST}`, `--port=${config.MYSQL_PORT}`, `--user=${config.MYSQL_USERNAME}`, `--default-character-set=${REQUIRED_CHARSET}`, '--batch', '--skip-column-names', DEVELOPMENT_DATABASE, '--execute=SELECT VERSION(), DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()', ]; } function assertSchemaHasNoSensitiveOrDataStatements(sql) { // 安全扫描只忽略 SQL 字符串字面量,标识符反引号仍参与检查。否则存储过程 // 合法返回“DEFINER=”等说明文字时会被误判,而真正的对象 DEFINER 仍可检出。 const sqlWithoutStringLiterals = sql.replace(/'(?:''|\\.|[^'\\])*'/gs, "''"); const prohibitedPatterns = [ [sqlWithoutStringLiterals, /\bDEFINER\s*=/i, 'DEFINER'], [sql, /^--\s*MySQL dump\b/im, 'MySQL dump 头'], [sql, /--\s*Dump completed on/i, '导出完成时间'], [sql, /--\s*Host:/i, '连接主机注释'], [sql, /\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i, '疑似凭据'], [sql, /\b(?:mysql|mariadb):\/\/[^\s'"`]+/i, '数据库连接串'], [sql, /\b(?:production|prod)[-_\s]*(?:host|db|database)?\s*[:=]\s*[^\s'"`]+/i, '疑似生产主机'], ]; for (const [content, pattern, label] of prohibitedPatterns) { if (pattern.test(content)) { throw new Error(`Schema SQL 包含禁止内容:${label}`); } } for (const statement of splitSqlStatements(sql)) { let executableSql = statement .replace( /^(?:\s*--[^\n]*(?:\n|$)|\s*#[^\n]*(?:\n|$)|\s*\/\*(?!\!)[\s\S]*?\*\/\s*)+/g, '', ) .trim(); const candidates = [executableSql]; const executableComment = executableSql.match(/^\/\*!\d{5}\s+([\s\S]*?)\*\/\s*/); if (executableComment) { candidates.push( executableComment[1].trim(), executableSql.slice(executableComment[0].length).trim(), ); } const dataStatement = candidates.find((candidate) => /^(?:INSERT\s+INTO|REPLACE\s+INTO|UPDATE\b|DELETE\s+FROM\b|TRUNCATE(?:\s+TABLE)?\b|LOAD\s+(?:DATA|XML)\b)/i.test( candidate, ), ); if (dataStatement) { const operation = dataStatement.match( /^(?:INSERT\s+INTO|REPLACE\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE(?:\s+TABLE)?|LOAD\s+(?:DATA|XML))/i, )[0]; throw new Error( `Schema SQL 包含禁止的数据操作:${operation}`, ); } } } function findDelimiterOutsideStrings(content, delimiter) { let quote = null; let escaped = false; for (let index = 0; index <= content.length - delimiter.length; index += 1) { const character = content[index]; if (quote) { if (escaped) { escaped = false; } else if (character === '\\') { escaped = true; } else if (character === quote) { if (content[index + 1] === quote && quote !== '`') { index += 1; } else { quote = null; } } continue; } if (character === "'" || character === '"' || character === '`') { quote = character; continue; } if (content.startsWith(delimiter, index)) return index; } return -1; } /** * mysqldump 用 DELIMITER 包住例程、触发器和事件。按真实分隔符切分后, * 只有顶层以 INSERT/REPLACE 开头的语句代表数据;对象体内部 DML 必须保留。 */ function splitSqlStatements(sql) { const statements = []; let delimiter = ';'; let buffer = ''; for (const line of String(sql).split(/(?<=\n)/)) { const directive = line.trim().match(/^DELIMITER\s+(\S+)$/i); if (directive) { if (buffer.trim()) statements.push(buffer.trim()); buffer = ''; delimiter = directive[1]; continue; } buffer += line; let delimiterIndex; while ((delimiterIndex = findDelimiterOutsideStrings(buffer, delimiter)) >= 0) { const statement = buffer.slice(0, delimiterIndex).trim(); if (statement) statements.push(statement); buffer = buffer.slice(delimiterIndex + delimiter.length); } } if (buffer.trim()) statements.push(buffer.trim()); return statements; } /** * 规范化只删除 mysqldump 的环境噪声,不重排或重新格式化业务定义。 * 表选项中的当前 AUTO_INCREMENT 值会随数据增长变化;列定义的自增属性不匹配 * “等号加数字”的模式,因此会原样保留。 */ export function normalizeSchemaSql(rawSql) { const normalized = String(rawSql) .replace(/\r\n?/g, '\n') .replace(/^-- MySQL dump .*\n/gm, '') .replace(/^-- Host:.*\n/gm, '') .replace(/^-- -+\n/gm, '') .replace(/^--\s*\n/gm, '') .replace(/^-- Server version.*\n/gm, '') .replace(/^-- Dump completed on .*\n?/gm, '') .replace(/\/\*!\d{5}\s+DEFINER=`[^`]+`@`[^`]+`\*\//g, '') .replace( /^(\/\*!\d{5}\s+)DEFINER=`[^`]+`@`[^`]+`\s+(SQL SECURITY\s+(?:DEFINER|INVOKER)\s+\*\/)$/gm, '$1$2', ) // 普通视图的 DEFINER 只允许出现在版本注释外、语句行首的 CREATE 前缀中; // 限定在行首可避免误删例程字符串或注释里的业务文本。 .replace(/^(\/\*!\d{5}\s+CREATE\s+[^\n]*?)\bDEFINER=`[^`]+`@`[^`]+`\s*/gm, '$1') .replace(/^(\) ENGINE=[^\n;]*?) AUTO_INCREMENT=\d+(?= )/gm, '$1') .replace(/^\s+/, '') .replace(/\s+$/, ''); const result = normalized.length > 0 ? `${normalized}\n` : ''; assertSchemaHasNoSensitiveOrDataStatements(result); return result; } function sha256(content) { return createHash('sha256').update(content, 'utf8').digest('hex'); } function countSchemaObjects(sql) { const definitions = String(sql); return Object.freeze({ tables: (definitions.match(/\bCREATE\s+TABLE\b/gi) ?? []).length, views: (definitions.match(/\b(?:CREATE\s+)?VIEW\s+`/gi) ?? []).length, triggers: (definitions.match(/\bTRIGGER\s+`/gi) ?? []).length, routines: (definitions.match(/\b(?:PROCEDURE|FUNCTION)\s+`/gi) ?? []).length, events: (definitions.match(/\bEVENT\s+`/gi) ?? []).length, }); } function buildManifest(fields) { // JSON 是 YAML 1.2 的严格子集;使用标准解析器生成和读取,避免手写 YAML // 对缩进、引号和同名嵌套字段产生歧义,同时仍保留约定的 manifest.yaml 文件名。 return `${JSON.stringify( { format_version: BASELINE_FORMAT_VERSION, status: fields.status, application_version: fields.applicationVersion, git_commit: fields.gitCommit, source_environment: 'development', database_name: DEVELOPMENT_DATABASE, mysql_client_version: fields.mysqlClientVersion, mysqldump_client_version: fields.mysqldumpClientVersion, mysql_server_version: fields.serverVersion, charset: REQUIRED_CHARSET, collation: REQUIRED_COLLATION, generated_at: fields.generatedAt, generator: `schema-baseline/${TOOL_VERSION}`, reason: fields.reason ?? 'none', schema_sha256: fields.schemaSha256 ?? 'not_applicable', objects: { tables: fields.captured, views: fields.captured, triggers: fields.captured, routines: fields.captured, events: fields.captured, }, object_counts: fields.objectCounts, }, null, 2, )}\n`; } export function buildCapturedArtifacts(metadata, sql) { assertMysql57Metadata(metadata); if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) { throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`); } const normalizedSql = normalizeSchemaSql(sql); const objectCounts = countSchemaObjects(normalizedSql); // mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。 if (Object.values(objectCounts).every((count) => count === 0)) { return buildCapturedEmptyArtifacts(metadata); } const schemaSha256 = sha256(normalizedSql); const manifest = buildManifest({ ...metadata, status: 'captured', captured: true, objectCounts, schemaSha256, }); return Object.freeze({ status: 'captured', schemaSha256, files: Object.freeze({ 'manifest.yaml': manifest, 'schema.sql': normalizedSql, 'checksum.sha256': `${schemaSha256} schema.sql\n`, }), }); } export function buildCapturedEmptyArtifacts(metadata) { assertMysql57Metadata(metadata); if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) { throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`); } const schemaSha256 = sha256(''); const objectCounts = Object.freeze(Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0]))); const manifest = buildManifest({ ...metadata, status: 'captured_empty', captured: false, objectCounts, schemaSha256, }); return Object.freeze({ status: 'captured_empty', schemaSha256, files: Object.freeze({ 'manifest.yaml': manifest }), }); } export function buildNotApplicableArtifacts(metadata) { if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) { throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`); } const manifest = buildManifest({ ...metadata, mysqlClientVersion: 'not_enabled', mysqldumpClientVersion: 'not_enabled', serverVersion: 'not_enabled', status: 'not_applicable', reason: 'mysql_disabled', captured: false, objectCounts: Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])), }); return Object.freeze({ status: 'not_applicable', files: Object.freeze({ 'manifest.yaml': manifest }), }); } function compareExistingArtifacts(targetDirectory, artifacts) { const actualFiles = readdirSync(targetDirectory).sort(); const expectedFiles = Object.keys(artifacts.files).sort(); if ( actualFiles.length !== expectedFiles.length || actualFiles.some((file, index) => file !== expectedFiles[index]) ) { return false; } return expectedFiles.every( (file) => readFileSync(path.join(targetDirectory, file), 'utf8') === artifacts.files[file], ); } /** * 先在目标父目录写入临时目录,再原子改名。这样异常不会留下半份基线; * 已发布版本目录只允许逐字节相同的幂等重跑,任何差异都必须人工留痕修正。 */ export function persistBaselineArtifacts(targetDirectory, artifacts) { if (existsSync(targetDirectory)) { if (compareExistingArtifacts(targetDirectory, artifacts)) { return { reused: true }; } throw new Error(`目标版本基线已存在且内容不同,禁止覆盖:${targetDirectory}`); } const parentDirectory = path.dirname(targetDirectory); mkdirSync(parentDirectory, { recursive: true }); const temporaryDirectory = path.join( parentDirectory, `.${path.basename(targetDirectory)}.tmp-${process.pid}-${Date.now()}`, ); mkdirSync(temporaryDirectory); try { for (const [file, content] of Object.entries(artifacts.files)) { writeFileSync(path.join(temporaryDirectory, file), content, 'utf8'); } renameSync(temporaryDirectory, targetDirectory); } catch (error) { rmSync(temporaryDirectory, { recursive: true, force: true }); throw error; } return { reused: false }; } function parseManifest(content) { try { return JSON.parse(content); } catch { throw new Error('manifest.yaml 不是有效的结构化 YAML/JSON 清单'); } } function assertExactFileSet(targetDirectory, expectedFiles) { const actualFiles = readdirSync(targetDirectory).sort(); const sortedExpected = [...expectedFiles].sort(); if ( actualFiles.length !== sortedExpected.length || actualFiles.some((file, index) => file !== sortedExpected[index]) ) { throw new Error( `Schema 基线文件集合无效,要求 ${sortedExpected.join(', ')},实际 ${actualFiles.join(', ')}`, ); } } export function verifyBaselineDirectory(targetDirectory, expectedRelease, expectedCommit) { if (!VERSION_PATTERN.test(expectedRelease ?? '')) { throw new Error(`待验证版本格式无效:${expectedRelease || '(空)'}`); } if (!existsSync(targetDirectory)) { throw new Error(`当前已发布版本缺少 Schema 基线:${targetDirectory}`); } const manifest = parseManifest( readFileSync(path.join(targetDirectory, 'manifest.yaml'), 'utf8'), ); const status = manifest.status; const applicationVersion = manifest.application_version; const generatedAt = Date.parse(manifest.generated_at ?? ''); const supportedFormat = (manifest.format_version === LEGACY_BASELINE_FORMAT_VERSION && manifest.generator === `schema-baseline/${LEGACY_TOOL_VERSION}`) || (manifest.format_version === BASELINE_FORMAT_VERSION && manifest.generator === `schema-baseline/${TOOL_VERSION}`); if ( !supportedFormat || !Number.isFinite(generatedAt) || !/^[0-9a-f]{40}$/.test(manifest.git_commit ?? '') ) { throw new Error('Schema 清单格式、生成器、时间或 Git 提交无效'); } requireExactValue(applicationVersion, expectedRelease, 'Schema 基线版本与发布版本不一致'); if (expectedCommit !== undefined) { requireExactValue( manifest.git_commit, expectedCommit, 'Schema 基线提交与发布标签不一致', ); } requireExactValue( manifest.source_environment, 'development', 'Schema 基线来源环境无效', ); requireExactValue( manifest.database_name, DEVELOPMENT_DATABASE, 'Schema 基线数据库名无效', ); requireExactValue(manifest.charset, REQUIRED_CHARSET, 'Schema 字符集无效'); requireExactValue( manifest.collation, REQUIRED_COLLATION, 'Schema 排序规则无效', ); if (status === 'not_applicable') { assertExactFileSet(targetDirectory, NOT_APPLICABLE_FILES); const reason = manifest.reason; requireExactValue(reason, 'mysql_disabled', 'Schema 不适用原因无效'); if ( manifest.mysql_client_version !== 'not_enabled' || manifest.mysqldump_client_version !== 'not_enabled' || manifest.mysql_server_version !== 'not_enabled' || manifest.schema_sha256 !== 'not_applicable' || Object.keys(manifest.objects ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',') || OBJECT_KEYS.some((key) => manifest.objects[key] !== false) ) { throw new Error('Schema 不适用清单必须证明 MySQL 未启用且所有对象均未捕获'); } return { status, applicationVersion, reason }; } if (status === 'captured_empty') { if (manifest.format_version !== BASELINE_FORMAT_VERSION) { throw new Error('空库 Schema 基线只允许 format v2'); } assertExactFileSet(targetDirectory, CAPTURED_EMPTY_FILES); assertMysql57Metadata({ mysqlClientVersion: manifest.mysql_client_version, mysqldumpClientVersion: manifest.mysqldump_client_version, serverVersion: manifest.mysql_server_version, databaseCharset: manifest.charset, databaseCollation: manifest.collation, }); const expectedEmptyChecksum = sha256(''); if ( manifest.schema_sha256 !== expectedEmptyChecksum || Object.keys(manifest.object_counts ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',') || OBJECT_KEYS.some((key) => manifest.object_counts[key] !== 0) ) { throw new Error('空库 Schema 清单必须证明所有对象计数为零'); } return { status, applicationVersion, schemaSha256: expectedEmptyChecksum }; } if (status !== 'captured') { throw new Error(`Schema 基线状态无效:${status}`); } assertExactFileSet(targetDirectory, CAPTURED_FILES); assertMysql57Metadata({ mysqlClientVersion: manifest.mysql_client_version, mysqldumpClientVersion: manifest.mysqldump_client_version, serverVersion: manifest.mysql_server_version, databaseCharset: manifest.charset, databaseCollation: manifest.collation, }); if (Object.keys(manifest.objects ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',')) { throw new Error('Schema 对象范围字段无效'); } for (const objectName of OBJECT_KEYS) { requireExactValue(manifest.objects?.[objectName], true, `Schema 对象范围缺少 ${objectName}`); } if ( manifest.format_version === BASELINE_FORMAT_VERSION && (Object.keys(manifest.object_counts ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',') || OBJECT_KEYS.some( (key) => !Number.isInteger(manifest.object_counts[key]) || manifest.object_counts[key] < 0, )) ) { throw new Error('Schema 对象计数字段无效'); } const sql = readFileSync(path.join(targetDirectory, 'schema.sql'), 'utf8'); if (!sql.trim()) { throw new Error('真实 Schema SQL 不能为空'); } assertSchemaHasNoSensitiveOrDataStatements(sql); if (/^(\) ENGINE=[^\n;]*?) AUTO_INCREMENT=\d+(?= )/m.test(sql)) { throw new Error('Schema SQL 仍包含当前 AUTO_INCREMENT 值'); } const schemaSha256 = sha256(sql); const manifestChecksum = manifest.schema_sha256; const checksumFile = readFileSync(path.join(targetDirectory, 'checksum.sha256'), 'utf8'); if (manifestChecksum !== schemaSha256 || checksumFile !== `${schemaSha256} schema.sql\n`) { throw new Error('Schema SQL、manifest 和 checksum 校验值不一致'); } return { status, applicationVersion, schemaSha256 }; } export function parseEnvironmentFile(content) { const config = {}; for (const rawLine of String(content).replace(/\r\n?/g, '\n').split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const separator = line.indexOf('='); if (separator <= 0) { throw new Error('开发环境配置包含无效行'); } const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); if (!/^[A-Z][A-Z0-9_]*$/.test(key)) { throw new Error(`开发环境配置键无效:${key}`); } config[key] = value; } return config; } function extractClientVersion(output) { const match = String(output).match(/\b(?:Distrib\s+)?(5\.7\.\d+(?:[-+][^\s,]+)?)/); return match?.[1] ?? String(output).trim(); } /** * 真实执行器可注入是为了让测试证明“关闭时零数据库调用”和命令边界;默认执行器 * 仍使用 execFileSync 参数数组。密码仅放入子进程环境 MYSQL_PWD,不会进入 argv。 */ export function runBaselineGeneration(options) { if ((options.commandArguments ?? []).length > 0) { throw new Error('Schema 基线命令不接受命令参数,不能覆盖环境、数据库或 env 路径'); } const config = assertSafeDevelopmentConfiguration( parseEnvironmentFile(readFileSync(options.envFile, 'utf8')), ); const targetDirectory = path.join( options.repositoryRoot, 'database', 'schema-baselines', options.applicationVersion, ); if (existsSync(targetDirectory)) { return verifyBaselineDirectory( targetDirectory, options.applicationVersion, options.gitCommit, ); } const commonMetadata = { applicationVersion: options.applicationVersion, gitCommit: options.gitCommit, generatedAt: options.generatedAt ?? new Date().toISOString(), }; if (!config.MYSQL_ENABLED) { const artifacts = buildNotApplicableArtifacts(commonMetadata); persistBaselineArtifacts(targetDirectory, artifacts); return verifyBaselineDirectory(targetDirectory, options.applicationVersion); } const executeFile = options.executeFile ?? execFileSync; const childEnvironment = { ...process.env, MYSQL_PWD: config.MYSQL_PASSWORD ?? '' }; const executionOptions = { cwd: options.repositoryRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: childEnvironment, }; const mysqlClientVersion = extractClientVersion( executeFile('mysql', ['--version'], executionOptions), ); const mysqldumpClientVersion = extractClientVersion( executeFile('mysqldump', ['--version'], executionOptions), ); assertMysql57Metadata({ mysqlClientVersion, mysqldumpClientVersion, serverVersion: '5.7.0-pending-server-check', databaseCharset: REQUIRED_CHARSET, databaseCollation: REQUIRED_COLLATION, }); const serverMetadata = String( executeFile('mysql', buildMysqlMetadataArguments(config), executionOptions), ) .trim() .split('\t'); if (serverMetadata.length !== 3) { throw new Error('无法从固定开发库读取 MySQL 版本、字符集和排序规则'); } const metadata = { ...commonMetadata, mysqlClientVersion, mysqldumpClientVersion, serverVersion: serverMetadata[0], databaseCharset: serverMetadata[1], databaseCollation: serverMetadata[2], }; assertMysql57Metadata(metadata); const rawSql = executeFile('mysqldump', buildMysqldumpArguments(config), executionOptions); const artifacts = buildCapturedArtifacts(metadata, rawSql); persistBaselineArtifacts(targetDirectory, artifacts); return verifyBaselineDirectory(targetDirectory, options.applicationVersion); } function runGit(repositoryRoot, args) { return execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim(); } export function getCurrentReleasedVersion(repositoryRoot) { const applicationVersion = runGit(repositoryRoot, [ 'describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', ]); if (!VERSION_PATTERN.test(applicationVersion)) { throw new Error(`最近发布标签格式无效:${applicationVersion}`); } return { applicationVersion, gitCommit: runGit(repositoryRoot, ['rev-list', '-n', '1', applicationVersion]), }; } /** 已发布清单是不可变历史;当前开发配置只决定下一次基线生成结果。 */ export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) { // 已发布清单记录的是发布当时的数据库启用状态。当前开发配置只能决定下一份 // 基线如何生成,不能反向否定或改写已发布版本的历史事实。 void repositoryRoot; return result; } export function assertCurrentReleasedBaseline(repositoryRoot) { const { applicationVersion, gitCommit } = getCurrentReleasedVersion(repositoryRoot); const result = verifyBaselineDirectory( path.join(repositoryRoot, 'database', 'schema-baselines', applicationVersion), applicationVersion, gitCommit, ); return assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result); } function runGitLines(repositoryRoot, args) { const output = runGit(repositoryRoot, args); return output ? output.split('\n').filter(Boolean) : []; } /** * 开始下一版本前使用严格门禁:本地校验只证明内容正确,不能证明它已经进入 * 可协作历史。这里进一步要求基线已跟踪、工作区无相关改动、当前 HEAD 已推送, * 且 origin 上的发布标签仍指向清单记录的提交。 */ export function assertCurrentReleasedBaselinePublished(repositoryRoot) { const release = getCurrentReleasedVersion(repositoryRoot); const relativeDirectory = path.posix.join( 'database', 'schema-baselines', release.applicationVersion, ); const result = verifyBaselineDirectory( path.join(repositoryRoot, relativeDirectory), release.applicationVersion, release.gitCommit, ); assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result); const trackedFiles = new Set( runGitLines(repositoryRoot, ['ls-files', '--', relativeDirectory]), ); const expectedFiles = result.status === 'captured' ? CAPTURED_FILES : NOT_APPLICABLE_FILES; for (const file of expectedFiles) { const relativeFile = path.posix.join(relativeDirectory, file); if (!trackedFiles.has(relativeFile)) { throw new Error(`Schema 基线尚未提交到 Git:${relativeFile}`); } } const localChanges = runGitLines(repositoryRoot, [ 'status', '--porcelain', '--', relativeDirectory, ]); if (localChanges.length > 0) { throw new Error(`Schema 基线存在未提交改动:${localChanges.join(', ')}`); } const branch = runGit(repositoryRoot, ['branch', '--show-current']); if (!branch) { throw new Error('Schema 发布门禁要求当前位于可推送分支'); } const localHead = runGit(repositoryRoot, ['rev-parse', 'HEAD']); assertRemoteBranchMatches(repositoryRoot, branch, runGit, localHead); const remoteTag = runGit(repositoryRoot, [ 'ls-remote', '--tags', 'origin', `refs/tags/${release.applicationVersion}^{}`, ]); const remoteTagCommit = remoteTag.split(/\s+/)[0]; requireExactValue(remoteTagCommit, release.gitCommit, '远程发布标签与基线提交不一致'); return result; } export function assertRemoteBranchMatches(repositoryRoot, branch, gitRunner = runGit, localHead) { const expectedHead = localHead ?? gitRunner(repositoryRoot, ['rev-parse', 'HEAD']); const remoteOutput = gitRunner(repositoryRoot, ['ls-remote', 'origin', `refs/heads/${branch}`]); const remoteHead = remoteOutput.split(/\s+/)[0]; requireExactValue(remoteHead, expectedHead, '当前分支尚未完整推送到 origin'); return true; } export function resolveFixedDevelopmentEnv(repositoryRoot) { const realDevelopmentEnv = path.join( repositoryRoot, 'deploy', 'env', 'development', 'api.env.local', ); if (existsSync(realDevelopmentEnv)) return realDevelopmentEnv; return path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env.example'); } function runCommandLine() { const [command, ...commandArguments] = process.argv.slice(2); if (!['generate', 'verify'].includes(command) || commandArguments.length > 0) { throw new Error('用法:node tools/database/schema-baseline.mjs (不接受额外参数)'); } const repositoryRoot = process.cwd(); const release = getCurrentReleasedVersion(repositoryRoot); const result = command === 'generate' ? runBaselineGeneration({ repositoryRoot, envFile: resolveFixedDevelopmentEnv(repositoryRoot), ...release, commandArguments, }) : assertCurrentReleasedBaseline(repositoryRoot); process.stdout.write( `Schema 基线验证通过:${release.applicationVersion} (${result.status})\n`, ); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { runCommandLine(); }