From 06466f388e0dab562b6746ae354b68039c512dd6 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 18 Aug 2026 01:21:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(GOV-012):=20=E5=BB=BA=E7=AB=8B=E7=BA=AF=20?= =?UTF-8?q?Node=20=E5=AF=BC=E5=87=BA=E5=99=A8=E5=AE=89=E5=85=A8=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitnexus/impact-plan.md | 26 +++++++ package.json | 1 + pnpm-lock.yaml | 3 + tools/database/mysql2-schema-exporter.mjs | 74 +++++++++++++++++++ .../database/mysql2-schema-exporter.test.mjs | 54 ++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 tools/database/mysql2-schema-exporter.mjs create mode 100644 tools/database/mysql2-schema-exporter.test.mjs diff --git a/docs/features/GOV-012-node-schema-exporter/gitnexus/impact-plan.md b/docs/features/GOV-012-node-schema-exporter/gitnexus/impact-plan.md index 3347805..3d3adc4 100644 --- a/docs/features/GOV-012-node-schema-exporter/gitnexus/impact-plan.md +++ b/docs/features/GOV-012-node-schema-exporter/gitnexus/impact-plan.md @@ -34,3 +34,29 @@ ## 高风险处置 这是全部版本开发的关键路径,风险至少为 HIGH。修改任何相关函数前必须重新执行 GitNexus upstream impact;若 MCP 仍不可用,使用静态调用关系、现有 50 条流程和全量回归交叉验证。历史基线失败、数据语句进入 SQL、生产边界被放宽或自动门禁未恢复时立即停止发布并回退。 + +## GOV-012 Task 1 重新核对(2026-08-18) + +### 工具状态与静态替代证据 + +- 已执行 `npx gitnexus analyze`,结果为 `Already up to date`;已执行 `npx gitnexus status`,索引和当前提交均为 `235163c`,状态为 up-to-date。 +- GitNexus MCP 在本次开发环境没有可调用传输入口;结合任务启动时持续的 `Transport closed`,不能把 MCP 不可用解释为无影响。本轮使用 `rg -n` 对五个符号检索全部仓库静态引用(排除 `node_modules`、`.git` 与 `.gitnexus`),并以已刷新的 CLI 索引和后续 `git diff` 交叉验证。 +- 静态引用集中在 `tools/database/schema-baseline.mjs`、`tools/database/schema-baseline.test.mjs`、GOV-007/009/011/012 的治理档案及 `tools/runtime/verify-runtime.mjs`;Task 1 不修改这些既有执行符号或 format v1/v2 目录。 +- 以本 worktree 路径指定 CLI 后,`assertMysql57Metadata` 的 upstream 结果为 CRITICAL(4 个直接调用方、9 个受影响符号、5 组流程),`verifyBaselineDirectory` 为 HIGH(3 个直接调用方、6 个受影响符号、4 组流程)。前者较历史 5/10 计数少一,是当前索引未计入测试引用,不能降低历史关键路径风险判断。 +- CLI 将 `runBaselineGeneration` 和 `buildCapturedArtifacts` 显示为 LOW/0;与源码中 `runCommandLine -> runBaselineGeneration -> buildCapturedArtifacts` 的静态调用不一致,因此以静态调用补足,不记录为无影响。`runCommandLine` 与 `tools/runtime/verify-runtime.mjs` 中同名符号歧义,CLI 不支持按 UID 或文件路径消歧,使用 `schema-baseline.mjs` 主入口守卫的静态引用作为替代证据。 + +### 关键符号影响结论 + +| 符号 | 直接调用方/静态入口 | 受影响流程 | 实际风险与本任务控制 | +| --- | --- | --- | --- | +| `runBaselineGeneration` | `runCommandLine` 的 `generate` 分支;Schema 单元测试直接调用 | `schema:baseline` 生成、随后 `schema:verify`、当前发布基线校验和 `governance:start` | HIGH;后续异步替换会改变错误传播与注入边界。Task 1 只新建模块,不改此函数。 | +| `assertMysql57Metadata` | `buildCapturedArtifacts`、`buildCapturedEmptyArtifacts`、`verifyBaselineDirectory` 的 captured/captured_empty 分支、`runBaselineGeneration` 与 Schema 单元测试 | captured、captured_empty、历史目录验证、当前发布基线、基线生成和 `governance:start` | CRITICAL;历史 GitNexus 证据为 5 个直接调用方、10 个受影响符号和 5 组流程。Task 1 建立独立 `assertMysql57ServerMetadata`,不改变 v1/v2 语义。 | +| `buildCapturedArtifacts` | `runBaselineGeneration`;Schema 单元测试 | captured 产物、清单/checksum、`schema:verify` | MEDIUM;v3 产物契约将在后续 Task 修改。本任务不改该函数。 | +| `verifyBaselineDirectory` | `runBaselineGeneration` 的已存在目录、MySQL 关闭和 captured 分支;`assertCurrentReleasedBaseline`;`assertCurrentReleasedBaselinePublished`;Schema 单元测试 | `schema:verify`、已发布基线校验、`governance:start` | HIGH;format v1/v2 只读兼容是发布门禁。本任务不改该函数或历史目录。 | +| `runCommandLine` | 模块主入口守卫;内部 `generate` 分支调用 `runBaselineGeneration` | `schema:baseline`、`schema:verify` 的 CLI 非零退出边界 | HIGH;后续改异步入口会影响命令完成时序。本任务不改此函数。 | + +### 跨模块同步判断 + +- HTTP/OpenAPI、Web API、消息契约、Redis、API、Scheduler、Worker、部署:本 Task 仅新增 Node 治理工具安全基础,不接入运行时服务,均无同步代码修改;边界是后续 Task 将替换 Schema CLI 内部实现。 +- 数据库:不建立连接、不查询业务数据、不执行 DDL/DML/多语句/锁表;本 Task 仅固定未来连接选项和服务端元数据契约。 +- 测试、依赖和文档:新增 mysql2 根开发依赖、Node 单元测试和本影响记录;`pnpm audit --audit-level high` 是依赖安全门禁。 diff --git a/package.json b/package.json index 0453b86..70cb6e8 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "eslint": "9.39.5", "eslint-plugin-vue": "9.33.0", "globals": "17.11.0", + "mysql2": "3.23.3", "prettier": "3.9.6", "typescript": "5.9.3", "typescript-eslint": "8.46.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c559c13..dbdfca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: globals: specifier: 17.11.0 version: 17.11.0 + mysql2: + specifier: 3.23.3 + version: 3.23.3(@types/node@22.20.0) prettier: specifier: 3.9.6 version: 3.9.6 diff --git a/tools/database/mysql2-schema-exporter.mjs b/tools/database/mysql2-schema-exporter.mjs new file mode 100644 index 0000000..2fb7ed4 --- /dev/null +++ b/tools/database/mysql2-schema-exporter.mjs @@ -0,0 +1,74 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const mysql2Version = require('mysql2/package.json').version; + +export const MYSQL2_EXPORTER_METADATA = Object.freeze({ + name: 'mysql2-show-create', + version: '1.0.0', + driverName: 'mysql2', + driverVersion: mysql2Version, +}); + +export const DEFAULT_EXPORT_LIMITS = Object.freeze({ + connectTimeoutMs: 5_000, + queryTimeoutMs: 5_000, + totalTimeoutMs: 30_000, + maxObjects: 5_000, + maxDefinitionBytes: 1_048_576, +}); + +export class SchemaExportError extends Error { + constructor(code, message) { + super(message); + this.name = 'SchemaExportError'; + this.code = code; + } +} + +/** + * 配置在 Schema 治理入口已完成 development、主机白名单和固定库名校验;这里仅映射 + * 连接所需字段,固定禁用多语句以防止未来元数据查询被拼接为任意 SQL。 + */ +export function buildConnectionOptions(config) { + return { + host: config.MYSQL_HOST, + port: Number(config.MYSQL_PORT), + user: config.MYSQL_USERNAME, + password: config.MYSQL_PASSWORD, + database: config.MYSQL_DATABASE, + charset: 'utf8mb4', + timezone: '+08:00', + connectTimeout: DEFAULT_EXPORT_LIMITS.connectTimeoutMs, + multipleStatements: false, + enableKeepAlive: true, + }; +} + +/** + * 元数据对象名仍需防御性转义,避免控制字符破坏 `SHOW CREATE` 查询边界;名称不合法时 + * 不回显输入,确保异常路径不会把潜在敏感值写入日志。 + */ +export function quoteIdentifier(identifier) { + if (typeof identifier !== 'string' || identifier.length === 0 || /[\u0000-\u001F\u007F]/.test(identifier)) { + throw new SchemaExportError('INVALID_IDENTIFIER', 'Schema 标识符不能为空且不得包含控制字符'); + } + + return `\`${identifier.replaceAll('`', '``')}\``; +} + +/** + * 导出器只接受项目数据库兼容基线,拒绝 MariaDB 和 MySQL 5.7 之外的服务器,避免相同 + * `SHOW CREATE` 文本在不兼容语义下被误认为可恢复的 Schema 快照。 + */ +export function assertMysql57ServerMetadata(metadata) { + if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata?.serverVersion ?? '')) { + throw new SchemaExportError('UNSUPPORTED_SERVER_VERSION', 'MySQL 服务端必须为 5.7.x'); + } + if (metadata.databaseCharset !== 'utf8mb4') { + throw new SchemaExportError('INVALID_DATABASE_CHARSET', '数据库字符集必须为 utf8mb4'); + } + if (metadata.databaseCollation !== 'utf8mb4_general_ci') { + throw new SchemaExportError('INVALID_DATABASE_COLLATION', '数据库排序规则必须为 utf8mb4_general_ci'); + } +} diff --git a/tools/database/mysql2-schema-exporter.test.mjs b/tools/database/mysql2-schema-exporter.test.mjs new file mode 100644 index 0000000..30ae478 --- /dev/null +++ b/tools/database/mysql2-schema-exporter.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_EXPORT_LIMITS, + MYSQL2_EXPORTER_METADATA, + SchemaExportError, + assertMysql57ServerMetadata, + buildConnectionOptions, + quoteIdentifier, +} from './mysql2-schema-exporter.mjs'; + +const config = Object.freeze({ + MYSQL_HOST: '127.0.0.1', + MYSQL_PORT: '3306', + MYSQL_DATABASE: 'a_share_analysis_dev', + MYSQL_USERNAME: 'schema_reader', + MYSQL_PASSWORD: 'not-for-logs', +}); + +test('连接配置固定单连接、utf8mb4、东八区且禁用多语句', () => { + const options = buildConnectionOptions(config); + assert.deepEqual(options, { + host: '127.0.0.1', + port: 3306, + user: 'schema_reader', + password: 'not-for-logs', + database: 'a_share_analysis_dev', + charset: 'utf8mb4', + timezone: '+08:00', + connectTimeout: 5_000, + multipleStatements: false, + enableKeepAlive: true, + }); +}); + +test('标识符转义反引号并拒绝空值和控制字符', () => { + assert.equal(quoteIdentifier('market`view'), '`market``view`'); + for (const value of ['', 'line\nbreak', 'zero\0byte']) { + assert.throws(() => quoteIdentifier(value), SchemaExportError); + } +}); + +test('只接受 MySQL 5.7.x 与固定字符规则', () => { + const valid = { + serverVersion: '5.7.42-log', + databaseCharset: 'utf8mb4', + databaseCollation: 'utf8mb4_general_ci', + }; + assert.doesNotThrow(() => assertMysql57ServerMetadata(valid)); + for (const serverVersion of ['5.6.51', '8.0.40', '10.6.18-MariaDB']) { + assert.throws(() => assertMysql57ServerMetadata({ ...valid, serverVersion })); + } +});