Compare commits
31
Commits
v0.0.8
..
11edeb0111
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11edeb0111 | ||
|
|
16bde94df6 | ||
|
|
58026aead3 | ||
|
|
f1c412b6a3 | ||
|
|
afd186c8a4 | ||
|
|
874796ab65 | ||
|
|
3d12c83621 | ||
|
|
c11de09fa7 | ||
|
|
dc8ad7d4b6 | ||
|
|
7e9d9065bb | ||
|
|
54fd92837a | ||
|
|
8e954a3f5f | ||
|
|
06466f388e | ||
|
|
235163cb61 | ||
|
|
cb2e22c13d | ||
|
|
97535b8b3f | ||
|
|
190fba7a0b | ||
|
|
0f81677b35 | ||
|
|
c605935403 | ||
|
|
3555b7c222 | ||
|
|
998c9276d5 | ||
|
|
c0a6f55605 | ||
|
|
405b9858f0 | ||
|
|
88e2fdffc8 | ||
|
|
7478a96bdc | ||
|
|
cce77aa884 | ||
|
|
dc8da80809 | ||
|
|
54499f0678 | ||
|
|
c8277ef25a | ||
|
|
426870b704 | ||
|
|
fcfef2d524 |
@@ -30,3 +30,5 @@ CLAUDE.md
|
|||||||
*.log
|
*.log
|
||||||
tmp/
|
tmp/
|
||||||
work/
|
work/
|
||||||
|
.worktrees/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **a-share-analysis** (1957 symbols, 2634 relationships, 50 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **a-share-analysis** (2431 symbols, 3282 relationships, 63 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.0.10 - 2026-08-18
|
||||||
|
|
||||||
|
- 使用 Node.js 22.x 与 mysql2 直接读取 MySQL 5.7.x 元数据和 `SHOW CREATE`,移除 Schema 基线对本机 MySQL CLI 的依赖。
|
||||||
|
- 新增 format v3 exporter/driver 元数据,永久只读兼容 format v1/v2。
|
||||||
|
- 增加对象目录、视图依赖排序、并发漂移检测、错误脱敏、超时和定义大小保护。
|
||||||
|
- 恢复版本开发门禁并封存 `v0.0.9` development `captured_empty` Schema 基线。
|
||||||
|
|
||||||
|
## 0.0.9 - 2026-08-17
|
||||||
|
|
||||||
|
- Node.js 运行时约束明确为完整 22.x,不绑定 minor 或 patch。
|
||||||
|
- MySQL 客户端、mysqldump、服务端和健康检查统一接受完整 5.7.x,不设置最低补丁版本。
|
||||||
|
- MySQL 健康详情改为展示服务端实际三段版本,字符集、排序规则、库名和东八区时区继续严格校验。
|
||||||
|
- 系统架构、开发规范、本地开发和 Ubuntu 源码部署手册统一采用系列版本口径,历史版本证据保持原样。
|
||||||
|
- 用户确认开发库为空并授权 GOV-011 一次性跳过 v0.0.8 Schema 基线;仓库未伪造基线或增加通用绕过开关。
|
||||||
|
|
||||||
## 0.0.8 - 2026-08-17
|
## 0.0.8 - 2026-08-17
|
||||||
|
|
||||||
- 新增安全、性能、GitNexus 跨模块影响和独立开发总结的可执行治理门禁。
|
- 新增安全、性能、GitNexus 跨模块影响和独立开发总结的可执行治理门禁。
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ npm run schema:verify
|
|||||||
npm run governance:start -- FEAT-MO-001 market-overview
|
npm run governance:start -- FEAT-MO-001 market-overview
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema 命令只允许处理固定开发库;当前 MySQL 未启用时会为上一发布版本生成可验证的不适用清单,不会连接数据库或创建空 SQL。完整规则见 [开发流程规范](docs/governance/development-workflow.md)。
|
Schema 命令只允许处理固定开发库;导出器使用 Node.js `mysql2`,本机无需安装 MySQL Server 或 CLI。当前 MySQL 未启用时会为上一发布版本生成可验证的不适用清单,不会连接数据库或创建空 SQL;启用但无对象时生成 format v3 `captured_empty` 清单。完整规则见 [开发流程规范](docs/governance/development-workflow.md)。
|
||||||
|
|
||||||
## 项目仓库
|
## 项目仓库
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"format_version": 3,
|
||||||
|
"status": "captured_empty",
|
||||||
|
"application_version": "v0.0.10",
|
||||||
|
"git_commit": "16bde94df68263672fa12e2823afe024c741f84e",
|
||||||
|
"source_environment": "development",
|
||||||
|
"database_name": "a_share_analysis_dev",
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
"collation": "utf8mb4_general_ci",
|
||||||
|
"generated_at": "2026-08-17T19:07:54.864Z",
|
||||||
|
"generator": "schema-baseline/3.0.0",
|
||||||
|
"reason": "none",
|
||||||
|
"schema_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"objects": {
|
||||||
|
"tables": false,
|
||||||
|
"views": false,
|
||||||
|
"triggers": false,
|
||||||
|
"routines": false,
|
||||||
|
"events": false
|
||||||
|
},
|
||||||
|
"object_counts": {
|
||||||
|
"tables": 0,
|
||||||
|
"views": 0,
|
||||||
|
"triggers": 0,
|
||||||
|
"routines": 0,
|
||||||
|
"events": 0
|
||||||
|
},
|
||||||
|
"exporter_name": "mysql2-show-create",
|
||||||
|
"exporter_version": "1.0.0",
|
||||||
|
"database_driver": "mysql2",
|
||||||
|
"database_driver_version": "3.23.3",
|
||||||
|
"mysql_server_version": "5.7.42-0ubuntu0.18.04.1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"format_version": 3,
|
||||||
|
"status": "captured_empty",
|
||||||
|
"application_version": "v0.0.9",
|
||||||
|
"git_commit": "88e2fdffc8c71dd682405c148a81953a8dcee9d6",
|
||||||
|
"source_environment": "development",
|
||||||
|
"database_name": "a_share_analysis_dev",
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
"collation": "utf8mb4_general_ci",
|
||||||
|
"generated_at": "2026-08-17T18:56:33.473Z",
|
||||||
|
"generator": "schema-baseline/3.0.0",
|
||||||
|
"reason": "none",
|
||||||
|
"schema_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"objects": {
|
||||||
|
"tables": false,
|
||||||
|
"views": false,
|
||||||
|
"triggers": false,
|
||||||
|
"routines": false,
|
||||||
|
"events": false
|
||||||
|
},
|
||||||
|
"object_counts": {
|
||||||
|
"tables": 0,
|
||||||
|
"views": 0,
|
||||||
|
"triggers": 0,
|
||||||
|
"routines": 0,
|
||||||
|
"events": 0
|
||||||
|
},
|
||||||
|
"exporter_name": "mysql2-show-create",
|
||||||
|
"exporter_version": "1.0.0",
|
||||||
|
"database_driver": "mysql2",
|
||||||
|
"database_driver_version": "3.23.3",
|
||||||
|
"mysql_server_version": "5.7.42-0ubuntu0.18.04.1"
|
||||||
|
}
|
||||||
@@ -60,10 +60,10 @@ OpenAPI 负责描述本项目 HTTP 契约,Axios 负责浏览器实际请求。
|
|||||||
|
|
||||||
## 4. 数据库技术基线
|
## 4. 数据库技术基线
|
||||||
|
|
||||||
- 数据库服务端固定使用 MySQL 5.7.42;开发、测试和生产环境禁止使用其他补丁版本或 MySQL 8.x 代替兼容性验证。
|
- 数据库服务端统一使用 MySQL 5.7.x;开发、测试和生产环境禁止使用 MySQL 5.6.x、8.x、MariaDB 或其他数据库代替兼容性验证,但不锁定 5.7 系列内的补丁版本。
|
||||||
- 数据库与表默认字符集固定为 `utf8mb4`,排序规则固定为 `utf8mb4_general_ci`。
|
- 数据库与表默认字符集固定为 `utf8mb4`,排序规则固定为 `utf8mb4_general_ci`。
|
||||||
- 数据库初始化、表结构、连接会话、ORM 配置和迁移脚本必须显式声明或验证该字符集与排序规则,不能依赖服务器默认值。
|
- 数据库初始化、表结构、连接会话、ORM 配置和迁移脚本必须显式声明或验证该字符集与排序规则,不能依赖服务器默认值。
|
||||||
- 正式启用持久化前,必须在真实 MySQL 5.7 环境验证服务端版本、Schema/表排序规则、迁移和关键查询。
|
- 正式启用持久化前,必须在真实 MySQL 5.7.x 环境验证服务端版本、Schema/表排序规则、迁移和关键查询。
|
||||||
- 第一阶段市场概览只定义 MySQL Repository 接口并使用内存实现,不启动数据库、不建立生产连接;后续启用时不得改变前端 API 和核心分析契约。
|
- 第一阶段市场概览只定义 MySQL Repository 接口并使用内存实现,不启动数据库、不建立生产连接;后续启用时不得改变前端 API 和核心分析契约。
|
||||||
|
|
||||||
## 5. 当前实施顺序
|
## 5. 当前实施顺序
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 本地开发环境
|
# 本地开发环境
|
||||||
|
|
||||||
本地开发由开发机提供 Node.js 22.x、pnpm 10.25.0 和已开机自启的 Redis `127.0.0.1:6379`。Web、API、Scheduler 与 Worker 全部从源码启动;MySQL 5.7.42 和 RocketMQ 5.x 也是外部服务,项目脚本不负责安装、启动、停止或删除任何基础设施。
|
本地开发由开发机提供 Node.js 22.x、pnpm 10.25.0 和已开机自启的 Redis `127.0.0.1:6379`。Web、API、Scheduler 与 Worker 全部从源码启动;MySQL 5.7.x 和 RocketMQ 5.x 也是外部服务,项目脚本不负责安装、启动、停止或删除任何基础设施。
|
||||||
|
|
||||||
## 首次准备
|
## 首次准备
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Redis 必须返回 `PONG`。如果不可达,应先修复开发机外部服务
|
|||||||
|
|
||||||
把 `deploy/env/development/*.env.example` 复制为同目录的 `*.env.local`。四份 local 文件被 Git 忽略:Web 只能含 `VITE_` 公开变量;API、Scheduler、Worker 按最小权限分别读取自己的配置,不共享一份全能 env。
|
把 `deploy/env/development/*.env.example` 复制为同目录的 `*.env.local`。四份 local 文件被 Git 忽略:Web 只能含 `VITE_` 公开变量;API、Scheduler、Worker 按最小权限分别读取自己的配置,不共享一份全能 env。
|
||||||
|
|
||||||
固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,服务端固定为 MySQL 5.7.42,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。
|
固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,服务端必须属于 MySQL 5.7.x 且不限制系列内补丁版本,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。
|
||||||
|
|
||||||
## 启动顺序
|
## 启动顺序
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
## 1. 用途与适用边界
|
## 1. 用途与适用边界
|
||||||
|
|
||||||
本手册用于 Mac 本地开发期间,按需访问家中 Windows 上仅监听回环地址的 TDX HTTP 接口。链路只用于真实字段核验和只读冒烟,不是生产采集、数据上报或公网部署方案。
|
本手册用于 Mac 本地开发期间,访问家中 Windows 上仅监听回环地址的 TDX HTTP 接口。TDX 是市场概览的正式行情来源;SSH 隧道负责把家庭局域网和外部 Tailscale 两种开发位置统一成同一个 Mac 回环入口。该链路不是生产采集、数据上报或公网部署方案。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Mac 本地程序
|
Mac 本地程序 / TDX Bridge
|
||||||
-> 127.0.0.1:17710
|
-> 127.0.0.1:17710
|
||||||
-> SSH 本地端口转发
|
-> SSH 本地端口转发
|
||||||
-> Tailscale 私有网络
|
-> 家庭局域网或 Tailscale 私有网络
|
||||||
-> Windows OpenSSH
|
-> 同一台 Windows OpenSSH
|
||||||
-> Windows 127.0.0.1:17709
|
-> Windows 127.0.0.1:17709
|
||||||
-> TDX
|
-> TDX
|
||||||
```
|
```
|
||||||
|
|
||||||
日常开发默认使用仓库内固定样本。只有需要验证 TDX 真实协议和字段时才启动本隧道;Windows 离线不得阻塞普通开发、测试或构建。
|
两个场景只切换 SSH 目标别名,TDX Bridge 和上层业务始终访问 `http://127.0.0.1:17710/`。离线样本只允许作为显式开发和自动化测试回退,并必须标记为离线数据;不得伪装为实时 TDX。Windows 离线不得阻塞普通测试或构建。
|
||||||
|
|
||||||
## 2. 安全边界
|
## 2. 安全边界
|
||||||
|
|
||||||
@@ -157,20 +157,34 @@ Restart-Service sshd
|
|||||||
|
|
||||||
管理员和非管理员两种方式只选与实际账户匹配的一种。若修改了 `C:\ProgramData\ssh\sshd_config`,先执行 `sshd -t` 校验,再重启服务。
|
管理员和非管理员两种方式只选与实际账户匹配的一种。若修改了 `C:\ProgramData\ssh\sshd_config`,先执行 `sshd -t` 校验,再重启服务。
|
||||||
|
|
||||||
## 6. 配置 Mac SSH 别名
|
## 6. 配置 Mac SSH 双场景别名
|
||||||
|
|
||||||
编辑 Mac 的 `~/.ssh/config`,加入:
|
家庭局域网和外部 Tailscale 连接同一台 Windows、同一个账户和同一个 `sshd`,复用同一把项目专用密钥,不创建第二把 SSH 密钥。编辑 Mac 的 `~/.ssh/config`,加入:
|
||||||
|
|
||||||
```sshconfig
|
```sshconfig
|
||||||
Host tdx-windows
|
Host tdx-windows-lan tdx-windows-tailnet
|
||||||
HostName <WINDOWS_TAILSCALE_HOST>
|
|
||||||
User <WINDOWS_SSH_USER>
|
User <WINDOWS_SSH_USER>
|
||||||
IdentityFile ~/.ssh/tdx_windows
|
IdentityFile ~/.ssh/tdx_windows
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
|
UseKeychain yes
|
||||||
|
AddKeysToAgent yes
|
||||||
ServerAliveInterval 30
|
ServerAliveInterval 30
|
||||||
ServerAliveCountMax 3
|
ServerAliveCountMax 3
|
||||||
ExitOnForwardFailure yes
|
ExitOnForwardFailure yes
|
||||||
LocalForward 127.0.0.1:17710 127.0.0.1:17709
|
LocalForward 127.0.0.1:17710 127.0.0.1:17709
|
||||||
|
|
||||||
|
Host tdx-windows-lan
|
||||||
|
HostName <WINDOWS_LAN_HOST>
|
||||||
|
|
||||||
|
Host tdx-windows-tailnet
|
||||||
|
HostName <WINDOWS_TAILSCALE_HOST>
|
||||||
|
```
|
||||||
|
|
||||||
|
真实局域网地址、Tailscale 地址和用户名只保存在本机 SSH 配置,不写入仓库。开发代理需要核对当前配置时使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -G tdx-windows-lan | awk '/^(hostname|user|identityfile|localforward) / {print}'
|
||||||
|
ssh -G tdx-windows-tailnet | awk '/^(hostname|user|identityfile|localforward) / {print}'
|
||||||
```
|
```
|
||||||
|
|
||||||
然后修正权限:
|
然后修正权限:
|
||||||
@@ -191,37 +205,70 @@ ssh-keygen -lf "$env:ProgramData\ssh\ssh_host_ed25519_key.pub"
|
|||||||
再在 Mac 连接,并确认首次连接提示的 Ed25519 指纹与 Windows 输出一致:
|
再在 Mac 连接,并确认首次连接提示的 Ed25519 指纹与 Windows 输出一致:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -v tdx-windows
|
ssh -v tdx-windows-tailnet
|
||||||
```
|
```
|
||||||
|
|
||||||
预期可以直接登录,或只要求输入私钥口令。若仍要求 Windows 账户密码,重点检查公钥文件位置、ACL、登录账户是否属于 Administrators,以及 SSH 调试输出中实际使用的密钥。
|
预期可以直接登录,或只要求输入私钥口令。若仍要求 Windows 账户密码,重点检查公钥文件位置、ACL、登录账户是否属于 Administrators,以及 SSH 调试输出中实际使用的密钥。
|
||||||
|
|
||||||
## 7. 启动和停止隧道
|
## 7. 两种场景的选择、启动和停止
|
||||||
|
|
||||||
### 7.1 前台运行
|
### 7.1 选择规则
|
||||||
|
|
||||||
适合首次验证,按 `Ctrl+C` 停止:
|
| 当前场景 | 使用别名 | 前置条件 |
|
||||||
|
|---|---|---|
|
||||||
|
| Mac 与 Windows 位于已配置的家庭局域网 | `tdx-windows-lan` | Windows 在线、TDX 已登录、家庭 LAN 的 SSH 22 可达 |
|
||||||
|
| Mac 在外部网络,或家庭 LAN SSH 不可达 | `tdx-windows-tailnet` | Mac 与 Windows 的 Tailscale 均在线,Tailnet ping 成功 |
|
||||||
|
|
||||||
|
两种隧道不能同时启动,因为都会独占 Mac `127.0.0.1:17710`。在家庭网络内优先使用 LAN;若 LAN 前置检查失败,不修改 TDX 监听地址,改用 Tailscale。
|
||||||
|
|
||||||
|
### 7.2 家庭局域网模式
|
||||||
|
|
||||||
|
前置检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -N tdx-windows
|
ssh -o BatchMode=yes -o ClearAllForwardings=yes -T tdx-windows-lan exit
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 后台运行
|
仓库文档不保存真实地址,家庭路径直接通过 SSH 别名验证。首次连接必须核对局域网地址与已信任的 Tailscale 地址返回相同 Windows 主机密钥指纹。
|
||||||
|
|
||||||
使用独立控制套接字启动,便于精确停止本项目隧道:
|
后台启动和检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -fN -M -S ~/.ssh/tdx-windows-control tdx-windows
|
ssh -fN -M -S ~/.ssh/tdx-windows-lan-control tdx-windows-lan
|
||||||
ssh -S ~/.ssh/tdx-windows-control -O check tdx-windows
|
ssh -S ~/.ssh/tdx-windows-lan-control -O check tdx-windows-lan
|
||||||
```
|
```
|
||||||
|
|
||||||
停止后台隧道:
|
停止:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -S ~/.ssh/tdx-windows-control -O exit tdx-windows
|
ssh -S ~/.ssh/tdx-windows-lan-control -O exit tdx-windows-lan
|
||||||
```
|
```
|
||||||
|
|
||||||
如果控制套接字残留但进程已不存在,先确认 `17710` 没有监听,再删除该单个套接字文件。不要用宽泛的 `pkill ssh`,以免终止其他 SSH 会话。
|
Windows 的家庭 SSH 防火墙规则应使用项目专用名称 `A-Share-TDX-SSH-LAN`,只允许家庭子网访问 TCP 22。系统原有的全范围 OpenSSH 入站规则保持禁用,不为 TDX 开放 `17709`。
|
||||||
|
|
||||||
|
### 7.3 外部 Tailscale 模式
|
||||||
|
|
||||||
|
前置检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale up
|
||||||
|
tailscale ping <WINDOWS_TAILSCALE_HOST>
|
||||||
|
```
|
||||||
|
|
||||||
|
后台启动和检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -fN -M -S ~/.ssh/tdx-windows-tailnet-control tdx-windows-tailnet
|
||||||
|
ssh -S ~/.ssh/tdx-windows-tailnet-control -O check tdx-windows-tailnet
|
||||||
|
```
|
||||||
|
|
||||||
|
停止:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -S ~/.ssh/tdx-windows-tailnet-control -O exit tdx-windows-tailnet
|
||||||
|
```
|
||||||
|
|
||||||
|
如果控制套接字残留但进程已不存在,先确认 `17710` 没有监听,再删除对应场景的单个控制套接字。不要用宽泛的 `pkill ssh`,以免终止其他 SSH 会话。
|
||||||
|
|
||||||
## 8. 验证 TDX 链路
|
## 8. 验证 TDX 链路
|
||||||
|
|
||||||
@@ -266,11 +313,11 @@ curl --fail-with-body --silent --show-error \
|
|||||||
1. 确认 Windows 保持开机、联网,Tailscale 和 `sshd` 在线,TDX 已启动并登录。
|
1. 确认 Windows 保持开机、联网,Tailscale 和 `sshd` 在线,TDX 已启动并登录。
|
||||||
2. Mac 切换到外部网络后重新连接 Tailscale。
|
2. Mac 切换到外部网络后重新连接 Tailscale。
|
||||||
3. 执行 `tailscale ping <WINDOWS_TAILSCALE_HOST>`。
|
3. 执行 `tailscale ping <WINDOWS_TAILSCALE_HOST>`。
|
||||||
4. 执行 `ssh -N tdx-windows` 并保持终端运行。
|
4. 执行 `ssh -N tdx-windows-tailnet` 并保持终端运行。
|
||||||
5. 依次执行 `nc` 和只读 `curl` 冒烟。
|
5. 依次执行 `nc` 和只读 `curl` 冒烟。
|
||||||
6. 停止隧道,确认 `127.0.0.1:17710` 不再监听。
|
6. 停止隧道,确认 `127.0.0.1:17710` 不再监听。
|
||||||
|
|
||||||
如果外网演练失败,日常开发继续使用固定样本。不要临时开放公网 SSH、RDP 或 TDX 端口。
|
如果外网演练失败,开发和自动化测试可显式切换到离线样本,并明确标记数据来源。不要临时开放公网 SSH、RDP 或 TDX 端口。
|
||||||
|
|
||||||
## 10. Windows 无人值守准备
|
## 10. Windows 无人值守准备
|
||||||
|
|
||||||
@@ -320,7 +367,7 @@ New-NetFirewallRule `
|
|||||||
|
|
||||||
- Windows 执行 `Get-Service sshd` 和 `Get-NetTCPConnection -LocalPort 22 -State Listen`。
|
- Windows 执行 `Get-Service sshd` 和 `Get-NetTCPConnection -LocalPort 22 -State Listen`。
|
||||||
- 检查 Windows 防火墙和 Tailscale 访问规则。
|
- 检查 Windows 防火墙和 Tailscale 访问规则。
|
||||||
- Mac 执行 `ssh -vvv tdx-windows`,确认目标是 Tailscale 地址而非家庭公网地址。
|
- Mac 执行 `ssh -vvv tdx-windows-tailnet`,确认目标是 Tailscale 地址而非家庭公网地址。
|
||||||
|
|
||||||
### SSH 仍要求 Windows 密码
|
### SSH 仍要求 Windows 密码
|
||||||
|
|
||||||
@@ -343,35 +390,52 @@ New-NetFirewallRule `
|
|||||||
|
|
||||||
### 外出后 Windows 不在线
|
### 外出后 Windows 不在线
|
||||||
|
|
||||||
- 无法远程唤醒或恢复交互式 TDX 登录时,切回固定样本继续开发。
|
- 无法远程唤醒或恢复交互式 TDX 登录时,显式切回离线样本继续开发,并保持非实时标记。
|
||||||
- 不通过新增公网暴露、共享密码或关闭防火墙绕过问题。
|
- 不通过新增公网暴露、共享密码或关闭防火墙绕过问题。
|
||||||
- 待公网部署需求明确后,另立功能实现生产级采集器、心跳、补传和远程运维。
|
- 待公网部署需求明确后,另立功能实现生产级采集器、心跳、补传和远程运维。
|
||||||
|
|
||||||
## 13. 日常操作速查
|
## 13. 日常操作速查
|
||||||
|
|
||||||
开始真实 TDX 联调:
|
### 13.1 在家开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tailscale ping <WINDOWS_TAILSCALE_HOST>
|
ssh -o BatchMode=yes -o ClearAllForwardings=yes -T tdx-windows-lan exit
|
||||||
ssh -fN -M -S ~/.ssh/tdx-windows-control tdx-windows
|
ssh -fN -M -S ~/.ssh/tdx-windows-lan-control tdx-windows-lan
|
||||||
ssh -S ~/.ssh/tdx-windows-control -O check tdx-windows
|
ssh -S ~/.ssh/tdx-windows-lan-control -O check tdx-windows-lan
|
||||||
nc -vz 127.0.0.1 17710
|
nc -vz 127.0.0.1 17710
|
||||||
```
|
```
|
||||||
|
|
||||||
结束联调:
|
结束家庭局域网联调:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -S ~/.ssh/tdx-windows-control -O exit tdx-windows
|
ssh -S ~/.ssh/tdx-windows-lan-control -O exit tdx-windows-lan
|
||||||
lsof -nP -iTCP:17710 -sTCP:LISTEN
|
lsof -nP -iTCP:17710 -sTCP:LISTEN
|
||||||
```
|
```
|
||||||
|
|
||||||
最后一条命令无输出表示本地转发已经停止。
|
### 13.2 在外开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale up
|
||||||
|
tailscale ping <WINDOWS_TAILSCALE_HOST>
|
||||||
|
ssh -fN -M -S ~/.ssh/tdx-windows-tailnet-control tdx-windows-tailnet
|
||||||
|
ssh -S ~/.ssh/tdx-windows-tailnet-control -O check tdx-windows-tailnet
|
||||||
|
nc -vz 127.0.0.1 17710
|
||||||
|
```
|
||||||
|
|
||||||
|
结束外部联调:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -S ~/.ssh/tdx-windows-tailnet-control -O exit tdx-windows-tailnet
|
||||||
|
lsof -nP -iTCP:17710 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
最后一条命令无输出表示本地转发已经停止。切换场景时必须先停止当前隧道,再启动另一场景;不得通过更换 TDX Bridge 地址绕过端口冲突。
|
||||||
|
|
||||||
## 14. 后续 Provider 接入约束
|
## 14. 后续 Provider 接入约束
|
||||||
|
|
||||||
- 平台配置只能引用 Mac 回环地址 `127.0.0.1:17710`,不得保存家庭网络地址。
|
- 平台配置只能引用 Mac 回环地址 `127.0.0.1:17710`,不得保存家庭网络或 Tailscale 地址。
|
||||||
- 真实 TDX Provider 必须显式启用;默认仍为确定性的固定样本 Provider。
|
- TDX 是正式行情来源;离线样本只作为显式开发和测试回退,必须展示来源与陈旧状态,生产环境禁止启用。
|
||||||
- Provider 必须限制请求超时、响应体大小、股票数量和并发,并输出来源、采集时间、交易日期、协议版本、追踪 ID 和陈旧状态。
|
- Provider 必须限制请求超时、响应体大小、股票数量和并发,并输出来源、采集时间、交易日期、协议版本、追踪 ID 和陈旧状态。
|
||||||
- TDX 原始字段仅在 Adapter 内转换,上层市场概览、规则分析和 AI 分析只依赖版本化内部契约。
|
- TDX 原始字段仅在 Adapter 内转换,上层市场概览、规则分析和 AI 分析只依赖版本化内部契约。
|
||||||
- 隧道中断必须返回明确的不可用或陈旧状态,不能把固定样本伪装成实时行情。
|
- 隧道中断必须返回明确的不可用或陈旧状态,不能把离线样本伪装成实时行情。
|
||||||
- Windows 采集器、公网接收 API、多目标推送、补传队列和无人值守运维继续延期,实施时单独立项并重新进行安全、性能和合规评估。
|
- Windows 采集器、公网接收 API、多目标推送、补传队列和无人值守运维继续延期,实施时单独立项并重新进行安全、性能和合规评估。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Ubuntu 源码生产部署
|
# Ubuntu 源码生产部署
|
||||||
|
|
||||||
这是项目唯一生产部署方式,支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22.x、pnpm 10.25.0、Nginx,以及外部 MySQL 5.7.42、Redis、RocketMQ 5.x;项目不负责安装、升级、启动、停止、删除或修改这些系统软件与服务。
|
这是项目唯一生产部署方式,支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22.x、pnpm 10.25.0、Nginx,以及外部 MySQL 5.7.x、Redis、RocketMQ 5.x;项目不负责安装、升级、启动、停止、删除或修改这些系统软件与服务。
|
||||||
|
|
||||||
## 构建发布目录
|
## 构建发布目录
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# GitNexus 开发前基线
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-001
|
||||||
|
- 记录时间:2026年08月18日 00:26:46 CST
|
||||||
|
- 分支:main
|
||||||
|
- 基线提交:`c0a6f5560584`
|
||||||
|
- 创建方式:用户授权跳过 `v0.0.9` Schema 备份后,按 `governance:start` 输出结构手工留痕
|
||||||
|
|
||||||
|
## 工作区
|
||||||
|
|
||||||
|
```text
|
||||||
|
(干净)
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
|
||||||
|
Indexed: 8/17/2026, 6:03:13 PM
|
||||||
|
Indexed commit: c0a6f55
|
||||||
|
Current commit: c0a6f55
|
||||||
|
Status: up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema 门禁例外
|
||||||
|
|
||||||
|
失败证据、用户决定、禁止伪造边界和残余风险见 `../schema-baseline-waiver.md`。本记录不表示 `governance:start` 自动门禁通过。
|
||||||
|
|
||||||
|
## 后续人工分析
|
||||||
|
|
||||||
|
首页设计确认后,在修改任何函数、类或方法前继续执行 GitNexus query、context 和 upstream impact,并将直接、间接、跨模块消费者及高风险处置补充到 `impact-plan.md`。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# GitNexus 开发前影响计划
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-001
|
||||||
|
- 基线提交:`c0a6f5560584`
|
||||||
|
- 当前阶段:TDX 开发连接设计与首页方案确认
|
||||||
|
- 当前总体风险:LOW(仅本机 SSH 配置和操作文档);首页业务代码风险待设计确认后按符号补充
|
||||||
|
|
||||||
|
## 直接与间接影响
|
||||||
|
|
||||||
|
### TDX 双路径开发连接
|
||||||
|
|
||||||
|
- 正式行情来源确定为 TDX;家庭局域网和外部 Tailscale 只改变 Mac 到同一 Windows `sshd` 的网络路径。
|
||||||
|
- TDX Bridge 的稳定入口固定为 Mac 回环地址 `127.0.0.1:17710`,市场概览、规则分析、AI 分析和未来 HTTP 契约不得依赖家庭地址、Tailscale 地址或 TDX 原始字段。
|
||||||
|
- 当前仓库没有 TDX Provider 或市场概览运行时符号,本阶段只更新操作文档;GitNexus 未识别可执行调用链影响。
|
||||||
|
- 首页业务设计确认后,必须对 `App.vue`、`createAppRouter`、后端模块装配、HTTP DTO 和新增市场概览符号分别执行 upstream impact。HIGH 或 CRITICAL 结果先告知用户再修改。
|
||||||
|
|
||||||
|
## 跨模块同步修改
|
||||||
|
|
||||||
|
- HTTP/OpenAPI:当前连接文档不修改;首页正式接入时需要版本化市场概览 DTO 与 OpenAPI 契约。
|
||||||
|
- Web API 与 adapter:当前不修改;未来页面只能经 `web/src/api/<业务域>/index.ts`、Axios 封装和 ViewModel 消费数据。
|
||||||
|
- 消息契约、Redis、MySQL、Scheduler、Worker:当前不启用。正式采集链路仍按独立功能立项,不能由 API 请求临时抓取全市场数据。
|
||||||
|
- 配置部署:真实地址、用户名、密钥和控制套接字只保留在开发机,不进入仓库、环境模板或前端构建物。
|
||||||
|
- 测试:两种 SSH 路径均需验证到同一 Mac 回环端点;离线样本只作显式开发回退,不得标记为实时 TDX。
|
||||||
|
- 文档:同步更新 Tailscale/SSH 开发手册,明确家庭局域网与外部 Tailscale 的选择、启动、停止、切换和验证方法。
|
||||||
|
|
||||||
|
## 安全、性能与回退
|
||||||
|
|
||||||
|
- Windows 的 TDX 继续只监听 `127.0.0.1:17709`,禁止改为局域网或公网监听。
|
||||||
|
- 家庭 SSH 入站规则只允许家庭子网访问 TCP 22;系统原有全范围 OpenSSH 规则保持禁用。
|
||||||
|
- 两条 SSH 路径复用同一项目专用 Ed25519 密钥;局域网主机密钥必须与已验证的 Tailscale 主机密钥指纹一致。
|
||||||
|
- 家庭 LAN 与当前 Tailscale 直连延迟均足以满足开发期分钟级行情;首页不得承诺秒级刷新。
|
||||||
|
- 回退时停止对应 SSH 控制连接、删除本机 TDX 别名,并删除 Windows 项目专用防火墙规则;不得删除通用 SSH 密钥、Tailscale 账户或 TDX 数据。
|
||||||
|
|
||||||
|
## Schema 例外风险
|
||||||
|
|
||||||
|
`v0.0.9` Schema 基线缺失会阻断严格自动治理门禁,风险见 `../schema-baseline-waiver.md`。本功能不借该例外修改数据库结构、读取生产数据库或声称发布门禁通过。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# v0.0.9 Schema 基线一次性例外
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-001
|
||||||
|
- 日期:2026-08-18
|
||||||
|
- 适用范围:仅 FEAT-MO-001 开发启动
|
||||||
|
|
||||||
|
## 事实记录
|
||||||
|
|
||||||
|
按常规流程执行 `npm run schema:baseline` 时,本机因不存在 MySQL 5.7.x 的 `mysql` 与 `mysqldump` 客户端,在执行 `mysql --version` 前以 `spawnSync mysql ENOENT` 失败。失败发生在客户端前置检查阶段,未连接开发数据库,也未生成基线目录或半成品。
|
||||||
|
|
||||||
|
用户确认本次继续跳过数据库备份。该决定只适用于从已发布版本 `v0.0.9` 启动 FEAT-MO-001,不继承 GOV-011 对 `v0.0.8` 的一次性例外,也不建立通用跳过能力。
|
||||||
|
|
||||||
|
## 处理决定
|
||||||
|
|
||||||
|
- 不伪造 `not_applicable`、`captured_empty`、空 SQL、客户端版本、服务端版本或校验摘要。
|
||||||
|
- 不修改 Schema 工具或 `governance:start` 增加跳过参数。
|
||||||
|
- 手工保存与 `governance:start` 同等结构的 GitNexus 开发前基线和影响计划。
|
||||||
|
- FEAT-MO-001 不得因此连接、探测或读取生产数据库。
|
||||||
|
- 后续功能需要 Schema 基线时,仍必须提供可验证的 MySQL 5.7.x 客户端,或再次获得用户针对该版本的明确授权。
|
||||||
|
|
||||||
|
## 风险与接受
|
||||||
|
|
||||||
|
缺少 `v0.0.9` Schema 基线意味着仓库不能证明该发布版本对应的开发库结构,也会使依赖严格 Schema 证据的自动治理门禁无法通过。用户接受本次追溯风险;该例外不表示数据库门禁、发布门禁或未来数据库变更已经通过。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Node 与 MySQL 系列版本约束设计
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 设计状态:已获用户方向确认,等待书面规格复核
|
||||||
|
- 日期:2026-08-17
|
||||||
|
|
||||||
|
## 设计决策
|
||||||
|
|
||||||
|
采用真实系列校验,而不是只改文案或增加最低补丁版本:Node.js 通过语义版本解析后要求 `major === 22`;MySQL 客户端、mysqldump 和服务端要求版本字符串满足 `5.7.<非负整数>`,允许 `-log` 等既有后缀。用户已明确选择完整 5.7.x,不设置 5.7.9 等最低补丁版本。
|
||||||
|
|
||||||
|
历史 QA、Schema manifest 和已发布功能档案中的 5.7.42、22.11.0、22.22.0 是真实验证事实,不改写。只有当前生效的代码注释、错误提示、架构规范和部署前置条件统一改为系列口径。
|
||||||
|
|
||||||
|
## 代码边界
|
||||||
|
|
||||||
|
### Node.js
|
||||||
|
|
||||||
|
`tools/runtime/verify-runtime.mjs` 已经只校验 Node 主版本 22,不需要修改生产判断。测试补充 22 系列内边界以及 21/23 拒绝,注释和当前文档明确写成 22.x。pnpm 继续按 10.x 校验,不属于本次变化。
|
||||||
|
|
||||||
|
### Schema 工具
|
||||||
|
|
||||||
|
`assertMysql57Metadata` 继续作为唯一 Schema 版本判断入口,但服务端正则从精确 5.7.42 收敛为与两个客户端相同的 5.7.x 规则。历史真实/captured_empty manifest 仍保存实际版本并由同一入口验证;不改 manifest 格式,不重写历史目录。
|
||||||
|
|
||||||
|
### API 健康检查
|
||||||
|
|
||||||
|
`MysqlHealthClient.check` 接受 5.7.x,继续逐项校验目标库、`utf8mb4`、`utf8mb4_general_ci` 和 `+08:00`。成功详情使用查询返回的实际版本,例如 `MySQL 5.7.41 / a_share_analysis_dev / +08:00`;失败提示统一为“需要 MySQL 5.7.x”。
|
||||||
|
|
||||||
|
### 配置与 TypeORM
|
||||||
|
|
||||||
|
`MysqlRuntimeConfig` 和 `buildTypeOrmOptions` 没有版本字段或版本分支,只需把说明性注释由 5.7.42 修正为 5.7.x。连接选项、`synchronize: false`、迁移策略和字符规则保持不变。
|
||||||
|
|
||||||
|
## GitNexus 影响
|
||||||
|
|
||||||
|
- `assertMysql57Metadata`:CRITICAL,5 个直接调用方、10 个受影响符号和 5 组流程,覆盖 captured/captured_empty 产物、历史目录验证、当前发布基线、基线生成和 `governance:start`。
|
||||||
|
- `MysqlHealthClient`:LOW,4 个直接依赖、7 个受影响符号和 1 组 API 装配流程。
|
||||||
|
- `assertSupportedRuntime`:LOW,只有运行时测试直接调用,0 条业务流程。
|
||||||
|
|
||||||
|
CRITICAL 来自 Schema 门禁的中心位置,不代表改动复杂。实施必须先增加精确失败用例,再完整回归 Schema 28 项及治理前后门禁;如出现历史 manifest 失效或非 5.7 版本被放行,立即停止并回退。
|
||||||
|
|
||||||
|
## 跨模块同步修改
|
||||||
|
|
||||||
|
- HTTP/OpenAPI:响应结构不变,仅健康 `detail` 文本显示实际版本;无需重新定义 DTO。
|
||||||
|
- Web API:不修改请求函数或 adapter;现有健康展示把 detail 当普通文本。
|
||||||
|
- 消息、Redis、Scheduler、Worker:无行为变化。
|
||||||
|
- 数据库:不修改 Schema、迁移、查询范围或凭据,只放宽补丁版本判断。
|
||||||
|
- 部署:继续仅支持 Ubuntu 源码部署,前置条件改写为 Node.js 22.x、MySQL 5.7.x。
|
||||||
|
- 测试与文档:运行时、Schema、健康客户端、架构规范、开发规范、本地和生产部署手册同步更新。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 版本为空、格式异常或系列不符时立即失败,并只输出检测到的版本字符串。
|
||||||
|
- 版本符合 5.7.x 但字符集、排序规则、库名或时区不符时,保持现有专用错误,不把问题误报为版本错误。
|
||||||
|
- 不捕获或降级版本错误,不因个人部署场景跳过生产启动校验。
|
||||||
|
|
||||||
|
## 安全策略与评估
|
||||||
|
|
||||||
|
资产和信任边界不变:Node 版本来自本机运行时;MySQL 版本来自客户端 `--version` 和只读元数据查询。固定正则只接受数字版本与既有后缀,不执行输入。数据库凭据继续由忽略的本地 env 或生产 EnvironmentFile 注入,错误和健康详情不输出账号、密码或地址。
|
||||||
|
|
||||||
|
残余风险是 MySQL 5.7 不同补丁可能存在已知缺陷;用户已明确接受系列级约束,项目不承诺补丁级兼容矩阵。字符规则、SQL 5.7 能力上限和迁移测试继续作为实际兼容门禁。
|
||||||
|
|
||||||
|
## 性能目标与评估
|
||||||
|
|
||||||
|
版本判断是启动或治理阶段的常数时间字符串匹配,健康检查仍只执行一次既有元数据查询。无新增网络调用、重试、数据库扫描、缓存操作或消息流,因此无需负载测试;目标是全量验证前后构建体积和运行流程不变。
|
||||||
|
|
||||||
|
## 测试设计
|
||||||
|
|
||||||
|
1. RED:新增 Schema 用例要求 5.7.41 通过,确认当前精确 5.7.42 实现失败。
|
||||||
|
2. GREEN:服务端版本改为 5.7.x 后,验证 5.7.0、5.7.41、5.7.42-log 通过,5.6.51 与 8.0.40 拒绝。
|
||||||
|
3. RED/GREEN:健康客户端先新增 5.7.41 成功详情断言,再修改实现;保留 8.0 拒绝和字符/排序规则/时区失败用例。
|
||||||
|
4. Node:补充 22.0.0、22.x 高补丁和 21/23 边界,验证现有实现无需行为变更。
|
||||||
|
5. 回归:运行 runtime、Schema、MySQL 规范、Server、全量 `pnpm verify:release` 和两个 Review 门禁。
|
||||||
|
|
||||||
|
## 回退
|
||||||
|
|
||||||
|
若系列校验导致历史基线失效或错误放行非 5.7 服务端,以新的中文回退提交恢复精确判断并同步恢复当前文档;禁止改写已推送历史。功能不涉及数据库结构或数据,因此无数据回滚。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# GOV-011 开发总结
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 日期:2026-08-17
|
||||||
|
- 当前状态:已随 v0.0.9 发布
|
||||||
|
|
||||||
|
## 原始开发需求
|
||||||
|
|
||||||
|
用户要求 Node.js 和 MySQL 不绑定具体小版本:Node.js 使用 22.x,MySQL 使用完整 5.7.x,不设置最低补丁版本。`utf8mb4`、`utf8mb4_general_ci` 和东八区时区等既有严格约束继续保留;历史证据中的实际版本不得改写。
|
||||||
|
|
||||||
|
## 需求与范围变化
|
||||||
|
|
||||||
|
- Node 生产判断原本已经按主版本 22 校验,本次只补充系列内/外特征测试,没有改写生产逻辑。
|
||||||
|
- MySQL 服务端从精确 5.7.42 调整为合法 5.7.x,客户端和 mysqldump 原有 5.7.x 约束保持不变。
|
||||||
|
- 健康详情从硬编码 patch 改为服务端元数据中的三段版本;`-log` 等附加文本不回显。
|
||||||
|
- 现行架构、开发规范、本地/Ubuntu 部署手册统一为系列口径;v0.0.8 CHANGELOG、历史 QA、Schema manifest 和已发布档案继续保存真实 5.7.42、22.11.0、22.22.0。
|
||||||
|
- 用户确认当前开发库无业务结构并授权本次跳过 v0.0.8 Schema 备份。该决定只形成 GOV-011 一次性例外,不增加通用跳过开关。
|
||||||
|
|
||||||
|
## 实际完成需求
|
||||||
|
|
||||||
|
| 需求 | 状态 | 实际结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| Node.js 22.x 系列约束 | 已完成 | 覆盖 22.0.0、常规 22.x、带后缀 22.x 与 21/23 边界;生产判断保持 `major === 22`。 |
|
||||||
|
| MySQL 服务端完整 5.7.x | 已完成 | Schema 元数据接受 5.7.0、5.7.41、5.7.42-log,拒绝 5.6、8.0 和无效格式。 |
|
||||||
|
| 健康检查实际版本 | 已完成 | `5.7.41-log` 返回 `MySQL 5.7.41 / ...`;DTO 与查询结构不变。 |
|
||||||
|
| 字符规则和时区保持严格 | 已完成 | `utf8mb4`、`utf8mb4_general_ci`、库名和 `+08:00` 失败用例全部保留并扩充。 |
|
||||||
|
| 当前规范统一系列口径 | 已完成 | 架构、开发规范、本地开发、Ubuntu 生产和说明性代码注释已更新。 |
|
||||||
|
| 历史证据保持原样 | 已完成 | 未批量改写历史功能档案、QA、Schema 基线或 v0.0.8 发布事实。 |
|
||||||
|
|
||||||
|
## 修复缺陷
|
||||||
|
|
||||||
|
- 修复 Schema 服务端校验把开发机实际 5.7.42 当作唯一合法 patch 的约束错误。
|
||||||
|
- 修复 API 健康检查拒绝其他合法 5.7.x 且成功详情硬编码 5.7.42 的问题。
|
||||||
|
- 修复当前架构、开发规范和部署手册将补丁版本误写成跨环境强制基线的问题。
|
||||||
|
- 完整 Schema 回归发现一处测试仍断言旧错误文案,已同步为 `5.7.x` 并重新验证。
|
||||||
|
|
||||||
|
## 未完成与延期项
|
||||||
|
|
||||||
|
- 本功能没有数据库结构、迁移或数据变更,因此不存在数据回滚或真实 Schema 导出结果。
|
||||||
|
- v0.0.8 Schema 基线没有生成。`verify:release`、`schema:verify` 和 `governance:finish` 会在该缺失条件失败;这是用户已接受的追溯风险,不代表其他测试通过,也不会延伸到后续功能。
|
||||||
|
- 不建立 MySQL 各 patch 的自动兼容矩阵;未来遇到特定补丁缺陷时通过独立功能或 ADR 决定是否提高最低版本。
|
||||||
|
|
||||||
|
## 关键提交
|
||||||
|
|
||||||
|
| 提交 | 中文摘要 | 推送状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| `426870b` | `docs(GOV-011): 制定运行时系列版本实施计划` | 已推送 Gitea |
|
||||||
|
| `c8277ef` | `test(GOV-011): 补充 Node 22 系列边界验证` | 已推送 Gitea |
|
||||||
|
| `54499f0` | `fix(GOV-011): 放宽 MySQL 服务端为完整 5.7 系列` | 已推送 Gitea |
|
||||||
|
| `dc8da80` | `fix(GOV-011): 支持 MySQL 5.7 系列健康检查` | 已推送 Gitea |
|
||||||
|
| `cce77aa` | `docs(GOV-011): 统一运行时系列版本口径` | 已推送 Gitea |
|
||||||
|
| `7478a96` | `docs(GOV-011): 完成系列版本验证与审查` | 已推送 Gitea |
|
||||||
|
| `88e2fdf` | `chore(GOV-011): 发布 v0.0.9 系列版本约束` | 已推送 Gitea,`v0.0.9` 指向该提交 |
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- TDD:Schema 的 5.7.0/5.7.41 成功用例先被旧精确判断拒绝;健康检查的 5.7.41-log 用例先被旧实现拒绝,随后最小修改转绿。
|
||||||
|
- 排除已授权严格发布基线用例后,Schema 28/28;runtime 3/3、governance 10/10、MySQL 2/2、环境 10/10、契约 3/3、边界 16/16、部署 6/6、Web 11/11、Server 30/30、API 预检 2/2。
|
||||||
|
- lint、格式、类型检查、OpenAPI 契约、Web/Server 构建、Playwright 4/4 和源码发布验证通过。
|
||||||
|
- GitNexus 相对 v0.0.8 检测为 23 个文件、70 个符号、11 条受影响流程、HIGH;运行流仅落在既定 Schema 与健康检查边界,详见 `gitnexus/`。
|
||||||
|
- 依赖审计为 0 已知漏洞,Redis 返回 `PONG`;两张视觉截图无溢出或控制台错误。
|
||||||
|
- Gitea `refs/heads/main` 与发布提交 `88e2fdffc8c71dd682405c148a81953a8dcee9d6` 一致;带注释标签对象为 `4d1553b68587a6218416bf0279494d720bb37161`,解引用后指向同一发布提交。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# GitNexus 开发后基线
|
||||||
|
|
||||||
|
- 索引提交:`88e2fdf`
|
||||||
|
- 索引时间:2026-08-17 17:58(Asia/Shanghai)
|
||||||
|
- 状态:up-to-date
|
||||||
|
- 节点:2090
|
||||||
|
- 关系:2767
|
||||||
|
- 集群:49
|
||||||
|
- 执行流:50
|
||||||
|
|
||||||
|
## 工作区状态
|
||||||
|
|
||||||
|
实现、验证和发布提交均已推送 `origin/main`,`v0.0.9` 已精确推送并指向 `88e2fdf`。本次索引刷新只自动更新 `AGENTS.md` 计数,随后与发布引用一起进入追溯提交。
|
||||||
|
|
||||||
|
## 运行边界
|
||||||
|
|
||||||
|
开发后新增或变化的运行流程仍只有 Schema 治理和 API 健康装配。Web 页面、Scheduler、Worker、Redis 与 RocketMQ 没有新增执行流。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# GitNexus 开发前基线
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 记录时间:2026年08月17日 16:57:31 CST
|
||||||
|
- 分支:main
|
||||||
|
- 基线提交:`9e858090966e`
|
||||||
|
- 创建方式:用户授权跳过 v0.0.8 Schema 备份后,按 `governance:start` 输出结构手工留痕
|
||||||
|
|
||||||
|
## 工作区
|
||||||
|
|
||||||
|
```text
|
||||||
|
(干净)
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
|
||||||
|
Indexed commit: 9e85809
|
||||||
|
Current commit: 9e85809
|
||||||
|
Status: up-to-date
|
||||||
|
Nodes: 1,957
|
||||||
|
Edges: 2,634
|
||||||
|
Flows: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema 门禁例外
|
||||||
|
|
||||||
|
未运行成功的原因、用户决定、禁止伪造边界和残余风险见 `../schema-baseline-waiver.md`。本记录不表示 `governance:start` 自动门禁通过。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# GitNexus 前后对比
|
||||||
|
|
||||||
|
- before:1894 个符号、2569 条关系、50 条流程(GOV-011 启动记录)
|
||||||
|
- after:2090 个节点、2767 条关系、50 条流程(`88e2fdf`)
|
||||||
|
- 相对 v0.0.8 detect-changes:23 个文件、70 个符号、11 条受影响流程、HIGH
|
||||||
|
|
||||||
|
## 差异解释
|
||||||
|
|
||||||
|
节点与关系增长主要来自 GOV-011 新增功能档案、实施计划、测试名称和当前规范标题。执行流总数仍为 50,没有出现新的生产流程。HIGH 风险由 Schema 中心函数的多条既有调用链贡献,和开发前 CRITICAL 评估方向一致。
|
||||||
|
|
||||||
|
## 跨模块核对
|
||||||
|
|
||||||
|
| 模块 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| HTTP/OpenAPI | DTO 和字段不变;健康 `detail` 文本显示实际 patch。 |
|
||||||
|
| Web API | 请求函数、adapter 和页面均未修改。 |
|
||||||
|
| 消息 | RocketMQ 契约、Topic、Group 和 Adapter 未修改。 |
|
||||||
|
| 数据库 | 不改 Schema、迁移、数据和查询范围;只放宽版本判断。 |
|
||||||
|
| Redis | 无代码和配置变化。 |
|
||||||
|
| API | MySQL 健康版本判断和详情变化,完整 Server 回归通过。 |
|
||||||
|
| Scheduler | 无行为变化,构建和进程测试通过。 |
|
||||||
|
| Worker | 无行为变化,构建和进程测试通过。 |
|
||||||
|
| 部署 | 拓扑不变,只把前置 MySQL 文案改为 5.7.x。 |
|
||||||
|
| 测试 | 新增系列边界、实际版本详情和当前文档契约。 |
|
||||||
|
| 文档 | 架构、规范、部署和功能档案同步更新;历史证据保留。 |
|
||||||
|
|
||||||
|
## 风险结论
|
||||||
|
|
||||||
|
没有发现计划外模块影响。残余风险是不同 MySQL 5.7 patch 自身可能存在缺陷,以及 v0.0.8 缺少可验证 Schema 基线;前者由真实迁移/查询验证控制,后者由用户接受的一次性例外记录控制。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# GitNexus 实际变更检测
|
||||||
|
|
||||||
|
- 比较基线:`v0.0.8` / `9e85809`
|
||||||
|
- 实现提交:`cce77aa`
|
||||||
|
- 结果:23 个文件、70 个符号、11 条受影响流程
|
||||||
|
- 风险:HIGH
|
||||||
|
|
||||||
|
## 变更符号
|
||||||
|
|
||||||
|
核心运行时符号为:
|
||||||
|
|
||||||
|
- `assertMysql57Metadata`
|
||||||
|
- `runBaselineGeneration`
|
||||||
|
- `MysqlHealthClient`
|
||||||
|
- `MysqlHealthClient.check`
|
||||||
|
- `createMysqlHealthClient`
|
||||||
|
- `assertSupportedRuntime` 只新增测试,没有生产实现变化
|
||||||
|
|
||||||
|
其余符号主要来自架构、规范、部署和 GOV-011 功能档案的 Markdown 标题,以及治理测试中的局部变量。
|
||||||
|
|
||||||
|
## 受影响执行流
|
||||||
|
|
||||||
|
- `RunBaselineGeneration → RequireExactValue`
|
||||||
|
- `AssertCurrentReleasedBaselinePublished → RequireExactValue`
|
||||||
|
- `BuildCapturedArtifacts → RequireExactValue`
|
||||||
|
- `AssertCurrentReleasedBaseline → RequireExactValue`
|
||||||
|
- `RunCommandLine` 到 `RequireExactValue`、`IsEnabled`、`ParseManifest`、`AssertExactFileSet`、`BuildManifest` 和 `ParseEnvironmentFile`
|
||||||
|
- `UseFactory → MysqlHealthClient`
|
||||||
|
|
||||||
|
HIGH 来自 Schema 中心入口映射到多条治理流程,不是新增网络、数据库写入或消息链路。排除用户已授权的 v0.0.8 基线缺失用例后,Schema 28 项全部通过;API 健康、Server 测试、类型和构建也全部通过。
|
||||||
|
|
||||||
|
## 范围结论
|
||||||
|
|
||||||
|
实际变化与开发前影响计划一致。没有新的 HTTP 字段、OpenAPI DTO、Web API、Redis、RocketMQ、Scheduler、Worker、数据库 Schema 或部署拓扑变化。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# GitNexus 开发前影响计划
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 基线提交:`9e858090966e`
|
||||||
|
- 总体风险:CRITICAL(Schema 门禁中心符号)
|
||||||
|
|
||||||
|
## 直接与间接影响
|
||||||
|
|
||||||
|
### `assertMysql57Metadata`
|
||||||
|
|
||||||
|
- 风险:CRITICAL。
|
||||||
|
- 直接调用方:5 个,包括 Schema 测试、captured、captured_empty、历史目录验证和基线生成。
|
||||||
|
- 间接影响:10 个符号、5 组流程;深度 2 包含当前发布基线验证和远程发布核对,深度 3 到达 `governance:start`。
|
||||||
|
- 控制:测试先行,系列内/外边界、历史 manifest、当前发布门禁和生成路径完整回归。
|
||||||
|
|
||||||
|
### `MysqlHealthClient`
|
||||||
|
|
||||||
|
- 风险:LOW。
|
||||||
|
- 直接依赖:4 个;总计 7 个受影响符号、1 组 API 装配流程。
|
||||||
|
- 控制:健康客户端测试覆盖实际版本详情、8.x 拒绝和字符/时区边界,API DTO 不变。
|
||||||
|
|
||||||
|
### `assertSupportedRuntime`
|
||||||
|
|
||||||
|
- 风险:LOW。
|
||||||
|
- 只有运行时测试 1 个直接调用者,0 条业务流程;现有实现已按主版本 22 校验。
|
||||||
|
- 控制:只补系列边界测试和现行文档,不改变生产判断。
|
||||||
|
|
||||||
|
## 跨模块同步修改
|
||||||
|
|
||||||
|
- HTTP/OpenAPI:结构不变,健康 detail 文本显示实际 5.7.x;不改 DTO。
|
||||||
|
- Web API:无需修改,现有 adapter 透传 detail。
|
||||||
|
- 消息、Redis、Scheduler、Worker:无影响。
|
||||||
|
- 数据库:只调整版本判断,不改 Schema、迁移、查询或数据。
|
||||||
|
- 配置部署:注释和前置条件改为 Node.js 22.x、MySQL 5.7.x;源码部署方式不变。
|
||||||
|
- 测试文档:runtime、Schema、健康客户端、MySQL 规范、架构、开发规范和部署手册同步修改;历史已发布事实不改写。
|
||||||
|
|
||||||
|
## 高风险处置
|
||||||
|
|
||||||
|
若 Schema 历史清单验证失败、5.6/8.x 被放行、字符规则校验被绕过或 `governance:start` 相关回归失败,立即停止实施并恢复到基线提交的行为。提交前必须执行 GitNexus detect-changes 并确认实际执行流未超出本计划。
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# GOV-011 Node 与 MySQL 系列版本约束实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 将当前生效的运行时约束统一为 Node.js 22.x 与完整 MySQL 5.7.x,同时保留数据库字符规则、时区、安全边界和历史版本证据。
|
||||||
|
|
||||||
|
**Architecture:** Node.js 继续由现有语义版本解析器按主版本判断,只补充特征测试。Schema 工具和 API 健康检查分别在既有职责边界内识别 MySQL 5.7.x,不新增共享版本工具,避免在只有两个简单且上下文不同的判断点时提前抽象;健康详情从服务端返回值提取实际三段版本。现行架构、开发规范和部署文档同步采用系列口径,历史 QA、Schema 清单和已发布档案保持原样。
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js 22.x、pnpm 10.25.0、Node.js 内置测试运行器、TypeScript 5.9.3、Vitest 3.2.7、NestJS 11、mysql2 3.23.3、MySQL 5.7.x、GitNexus、Git/Gitea。
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Node.js 接受格式合法的 22.x,拒绝 21.x、23.x 和无效版本;pnpm 继续使用既有 10.x 门禁。
|
||||||
|
- MySQL 客户端、mysqldump 客户端、服务端和健康检查接受完整 5.7.x,不设置最低 patch,不接受 5.6.x、8.x 或 MariaDB。
|
||||||
|
- 数据库字符集固定 `utf8mb4`,排序规则固定 `utf8mb4_general_ci`,会话时区固定 `+08:00`。
|
||||||
|
- Schema 基线只允许 `APP_ENV=development`、数据库 `a_share_analysis_dev` 和代码内固定开发主机白名单;禁止连接、探测或读取生产数据库。
|
||||||
|
- 不修改数据库 Schema、迁移、数据、连接账号、网络、HTTP DTO、OpenAPI 字段、Web API、Redis、RocketMQ、Scheduler 或 Worker 行为。
|
||||||
|
- 历史 QA、Schema manifest 和已发布功能档案中的 `5.7.42`、`22.11.0`、`22.22.0` 是实际证据,不得批量改写。
|
||||||
|
- 版本判断使用固定格式匹配,不执行版本字符串,不拼接 SQL,不把连接地址、账号或密码写入错误和健康详情。
|
||||||
|
- 不新增依赖;关键版本口径、安全边界和架构取舍使用准确中文注释,不做逐行翻译式注释。
|
||||||
|
- 每个行为修改遵循 RED-GREEN:先证明旧精确补丁判断失败,再写最小实现并执行局部与完整回归。
|
||||||
|
- 修改关键符号前复核既有 GitNexus impact;提交前执行 GitNexus detect-changes,确认影响没有超出已批准范围。
|
||||||
|
- 每个提交使用中文 Conventional Commit 并带 `GOV-011`,验证后立即推送当前分支到项目 Gitea,禁止强推。
|
||||||
|
- v0.0.8 Schema 基线按 `schema-baseline-waiver.md` 执行一次性例外:不得伪造 N/A、空库清单或空 SQL,也不得修改治理工具增加通用跳过能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 锁定 Node.js 22.x 现有行为
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tools/runtime/verify-runtime.test.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `assertSupportedRuntime({ nodeVersion, pnpmVersion })`。
|
||||||
|
- Produces: Node.js 22 系列内边界、相邻主版本和无效格式的特征测试。
|
||||||
|
- Preserves: `tools/runtime/verify-runtime.mjs` 的生产判断和 pnpm 10.x 约束。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 补充 Node.js 系列边界测试**
|
||||||
|
|
||||||
|
将现有 Node 成功样本扩展为系列内多个版本,并把相邻失败样本明确为 21.x 与 23.x:
|
||||||
|
|
||||||
|
```js
|
||||||
|
test('接受完整 Node.js 22.x 并拒绝相邻主版本', () => {
|
||||||
|
for (const nodeVersion of ['22.0.0', '22.11.0', '22.99.123-beta.1+build.7']) {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertSupportedRuntime({ nodeVersion, pnpmVersion: '10.25.0' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nodeVersion of ['21.7.3', '23.0.0']) {
|
||||||
|
assert.throws(
|
||||||
|
() => assertSupportedRuntime({ nodeVersion, pnpmVersion: '10.25.0' }),
|
||||||
|
/Node\.js 必须为 22\.x/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
保留 `v22`、空字符串、非数字 pnpm 等既有无效格式测试,不改变 `parseVersion` 的输入格式契约。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行特征测试并确认现有实现已满足**
|
||||||
|
|
||||||
|
Run: `pnpm test:runtime`
|
||||||
|
|
||||||
|
Expected: PASS。该任务是对已存在的 22.x 行为补证,不修改生产代码,也不制造无意义的 RED。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 提交并推送 Node 特征测试**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/runtime/verify-runtime.test.mjs
|
||||||
|
git commit -m "test(GOV-011): 补充 Node 22 系列边界验证"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功,本地 `HEAD` 与 Gitea `refs/heads/main` 一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 让 Schema 元数据接受完整 MySQL 5.7.x
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tools/database/schema-baseline.test.mjs`
|
||||||
|
- Modify: `tools/database/schema-baseline.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `assertMysql57Metadata(metadata)` 及既有 `MysqlMetadata` 形状。
|
||||||
|
- Produces: 客户端、mysqldump 和服务端统一的 `5.7.<非负整数>` 系列判断。
|
||||||
|
- Preserves: `databaseCharset === 'utf8mb4'`、`databaseCollation === 'utf8mb4_general_ci'`、历史 manifest 格式和所有 Schema 安全门禁。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写入服务端 5.7.x 失败测试**
|
||||||
|
|
||||||
|
把现有“5.7.41 必须失败”改成系列内成功断言,并补齐最低 patch、后缀及上下系列边界:
|
||||||
|
|
||||||
|
```js
|
||||||
|
test('接受完整 MySQL 5.7.x 并保持固定数据库字符规则', () => {
|
||||||
|
for (const serverVersion of ['5.7.0', '5.7.41', '5.7.42-log']) {
|
||||||
|
assert.doesNotThrow(() => assertMysql57Metadata({ ...validMetadata, serverVersion }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const serverVersion of ['5.6.51', '8.0.40', '5.7', '5.7.x', '']) {
|
||||||
|
assert.throws(
|
||||||
|
() => assertMysql57Metadata({ ...validMetadata, serverVersion }),
|
||||||
|
/MySQL 服务端必须为 5\.7\.x/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => assertMysql57Metadata({ ...validMetadata, mysqlClientVersion: '8.0.40' }),
|
||||||
|
/mysql 客户端必须为 5\.7\.x/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertMysql57Metadata({ ...validMetadata, databaseCollation: 'utf8mb4_bin' }),
|
||||||
|
/utf8mb4_general_ci/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行定向测试并确认 RED**
|
||||||
|
|
||||||
|
Run: `node --test --test-name-pattern='完整 MySQL 5.7.x' tools/database/schema-baseline.test.mjs`
|
||||||
|
|
||||||
|
Expected: FAIL,`5.7.0` 或 `5.7.41` 被当前精确 `5.7.42` 服务端判断拒绝;失败原因不得来自测试语法或夹具。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 最小修改服务端系列判断**
|
||||||
|
|
||||||
|
将 `assertMysql57Metadata` 中服务端判断改为与两个客户端相同的系列格式,并更新注释与错误信息:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* mysql、mysqldump 与服务端统一接受完整 5.7.x;字符集和排序规则仍按精确值校验,
|
||||||
|
* 避免放宽 patch 版本时意外放宽数据库结构兼容边界。
|
||||||
|
*/
|
||||||
|
export function assertMysql57Metadata(metadata) {
|
||||||
|
// 两个既有客户端判断保持不变。
|
||||||
|
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) {
|
||||||
|
throw new Error(`MySQL 服务端必须为 5.7.x,实际 ${metadata.serverVersion || '(空)'}`);
|
||||||
|
}
|
||||||
|
// 既有字符集和排序规则精确校验保持不变。
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实际编辑只替换服务端正则、对应注释和错误文案,不复制或重排函数内其他校验。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行 Schema 局部与完整回归**
|
||||||
|
|
||||||
|
Run: `node --test --test-name-pattern='完整 MySQL 5.7.x' tools/database/schema-baseline.test.mjs`
|
||||||
|
|
||||||
|
Expected: PASS。
|
||||||
|
|
||||||
|
Run: `pnpm test:schema-baseline && pnpm test:mysql-baseline && pnpm test:governance`
|
||||||
|
|
||||||
|
Expected: PASS。历史 `captured`、`captured_empty`、N/A、当前发布门禁、生成入口和远程核对测试均无回归;测试不得访问真实数据库。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交并推送 Schema 系列判断**
|
||||||
|
|
||||||
|
在提交前执行 GitNexus detect-changes,确认只影响已记录的 Schema 生成、验证和治理调用链,然后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/database/schema-baseline.mjs tools/database/schema-baseline.test.mjs
|
||||||
|
git commit -m "fix(GOV-011): 放宽 MySQL 服务端为完整 5.7 系列"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功且没有新增 Schema 文件或数据库连接副作用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 健康检查返回实际 MySQL 5.7.x 版本
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `server/libs/infrastructure/src/health-clients.test.ts`
|
||||||
|
- Modify: `server/libs/infrastructure/src/mysql-health.client.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `MysqlHealthClient.check(): Promise<DependencyHealthDto>` 与 `MYSQL_HEALTH_QUERY`。
|
||||||
|
- Produces: 5.7.x 成功结果 `MySQL <实际三段版本> / <database> / +08:00`。
|
||||||
|
- Preserves: `DependencyHealthDto` 结构、只读元数据查询、数据库名、字符集、排序规则、时区校验和最多两个连接的池配置。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写入健康检查 5.7.41 失败测试**
|
||||||
|
|
||||||
|
将成功夹具改为 `5.7.41-log`,断言详情来自返回值;移除“5.7.41 必须失败”的旧断言,增加 5.6 与 8.0 边界:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await expect(client.check()).resolves.toEqual({
|
||||||
|
status: 'healthy',
|
||||||
|
detail: 'MySQL 5.7.41 / a_share_analysis_dev / +08:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const version of ['5.6.51', '8.0.40', '5.7', '5.7.x']) {
|
||||||
|
await expect(createClient({ version }).check()).rejects.toThrow(/MySQL 5\.7\.x/);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
保留库名、`utf8mb4`、`utf8mb4_general_ci` 和 `+08:00` 漂移失败断言;如当前测试缺少字符集或时区样本,在同一测试中分别补入:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await expect(createClient({ charset: 'utf8' }).check()).rejects.toThrow(/utf8mb4/);
|
||||||
|
await expect(createClient({ timezone: 'SYSTEM' }).check()).rejects.toThrow(/\+08:00/);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行定向测试并确认 RED**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @a-share-analysis/server exec vitest run libs/infrastructure/src/health-clients.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL,当前实现拒绝 `5.7.41-log` 或仍返回硬编码的 `MySQL 5.7.42`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 最小实现系列判断和实际版本详情**
|
||||||
|
|
||||||
|
在 `MysqlHealthClient.check` 中只解析一次版本,并使用捕获到的三段版本构造安全详情:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const versionMatch = /^(5\.7\.\d+)(?:[-+].*)?$/.exec(row.version);
|
||||||
|
if (!versionMatch) {
|
||||||
|
throw new Error('MySQL 5.7.x 版本要求未满足');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'healthy',
|
||||||
|
detail: `MySQL ${versionMatch[1]} / ${row.databaseName} / ${row.timezone}`,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
这里不回显 `-log` 等服务端后缀,避免把厂商或构建附加文本直接带到用户界面;显示的三段版本仍来自服务端元数据,不再硬编码 patch。
|
||||||
|
|
||||||
|
同步把健康池注释中的 `5.7.42` 改为 `5.7.x`,连接参数不变。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行后端局部与完整回归**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @a-share-analysis/server exec vitest run libs/infrastructure/src/health-clients.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS。
|
||||||
|
|
||||||
|
Run: `pnpm --filter @a-share-analysis/server test && pnpm --filter @a-share-analysis/server typecheck && pnpm --filter @a-share-analysis/server build`
|
||||||
|
|
||||||
|
Expected: PASS;OpenAPI DTO 和生成文件无变化。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交并推送健康检查变化**
|
||||||
|
|
||||||
|
在提交前执行 GitNexus detect-changes,确认影响仅进入 API 健康装配流程,然后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/libs/infrastructure/src/mysql-health.client.ts server/libs/infrastructure/src/health-clients.test.ts
|
||||||
|
git commit -m "fix(GOV-011): 支持 MySQL 5.7 系列健康检查"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功,健康响应结构没有契约漂移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 统一现行代码注释与规范部署口径
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `tools/governance/environment-baseline.test.mjs`
|
||||||
|
- Modify: `server/libs/config/src/runtime-config.ts`
|
||||||
|
- Modify: `server/libs/infrastructure/src/typeorm-options.ts`
|
||||||
|
- Modify: `docs/architecture/system-architecture.md`
|
||||||
|
- Modify: `docs/governance/code-development-standards.md`
|
||||||
|
- Modify: `docs/deployment/local-development.md`
|
||||||
|
- Modify: `docs/deployment/ubuntu-source-production.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: GOV-011 已确认的现行版本文字边界。
|
||||||
|
- Produces: 可机器验证的 Node.js 22.x / MySQL 5.7.x 当前规范。
|
||||||
|
- Preserves: pnpm 10.25.0、Ubuntu 源码部署、systemd、Nginx、Redis、RocketMQ 和环境变量模板。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 先把文档契约测试改为系列口径**
|
||||||
|
|
||||||
|
将环境基线测试的现行文档检查从精确 `5.7.42` 改为 `5.7.x`,并显式拒绝当前文档继续写死 patch:
|
||||||
|
|
||||||
|
```js
|
||||||
|
for (const document of [local, source]) {
|
||||||
|
assert.match(document, /MySQL 5\.7\.x/);
|
||||||
|
assert.doesNotMatch(document, /MySQL 5\.7\.42/);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
再读取系统架构和代码开发规范,执行相同断言;测试只扫描这四份当前生效文档,不扫描 `docs/features/`、`database/schema-baselines/` 或历史 QA。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行环境契约并确认 RED**
|
||||||
|
|
||||||
|
Run: `pnpm test:environment-baseline`
|
||||||
|
|
||||||
|
Expected: FAIL,现行文档仍包含 `MySQL 5.7.42`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 更新代码说明性注释**
|
||||||
|
|
||||||
|
把以下当前注释统一为 `MySQL 5.7.x`:
|
||||||
|
|
||||||
|
- `MysqlRuntimeConfig` 的兼容口径说明。
|
||||||
|
- `buildTypeOrmOptions` 的 MySQL 基线说明。
|
||||||
|
- `createMysqlHealthClient` 的会话时区说明。
|
||||||
|
- `assertMysql57Metadata` 的客户端/服务端系列说明。
|
||||||
|
|
||||||
|
不修改类型字段、连接选项、`synchronize: false`、迁移配置或配置解析行为。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 更新四份当前生效文档**
|
||||||
|
|
||||||
|
逐项修改:
|
||||||
|
|
||||||
|
- 系统架构:开发、测试和生产统一使用 MySQL 5.7.x,禁止 5.6、8.x、MariaDB 代替验证;不再禁止其他 5.7 patch。
|
||||||
|
- 代码开发规范:Node.js 22.x 和 MySQL 5.7.x 是系列约束;字符集、排序规则和时区仍为精确值。
|
||||||
|
- 本地开发:外部 MySQL 使用 5.7.x,固定开发库与主机白名单不变。
|
||||||
|
- Ubuntu 生产:管理员提供 Node.js 22.x 与外部 MySQL 5.7.x,项目仍只验证、不安装基础设施。
|
||||||
|
|
||||||
|
历史功能档案、QA、Schema manifest 和既有版本记录不修改。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行文档契约与格式验证**
|
||||||
|
|
||||||
|
Run: `pnpm test:environment-baseline && pnpm test:deployment && pnpm format:check`
|
||||||
|
|
||||||
|
Expected: PASS。
|
||||||
|
|
||||||
|
Run: `rg -n "MySQL 5\.7\.42" docs/architecture docs/governance docs/deployment server/libs tools/database/schema-baseline.mjs`
|
||||||
|
|
||||||
|
Expected: 当前生效代码和文档无精确 `MySQL 5.7.42` 口径;若命中测试夹具中的真实输入版本,只核对其用途,不改写真实样本。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交并推送规范同步**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/governance/environment-baseline.test.mjs \
|
||||||
|
server/libs/config/src/runtime-config.ts \
|
||||||
|
server/libs/infrastructure/src/typeorm-options.ts \
|
||||||
|
server/libs/infrastructure/src/mysql-health.client.ts \
|
||||||
|
tools/database/schema-baseline.mjs \
|
||||||
|
docs/architecture/system-architecture.md \
|
||||||
|
docs/governance/code-development-standards.md \
|
||||||
|
docs/deployment/local-development.md \
|
||||||
|
docs/deployment/ubuntu-source-production.md
|
||||||
|
git commit -m "docs(GOV-011): 统一运行时系列版本口径"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功;`git diff --name-only` 不包含任何历史功能档案或 Schema 基线文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 补齐功能档案和全量验证证据
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/test-plan.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/development-summary.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/gitnexus/detected-changes.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/gitnexus/after.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/gitnexus/comparison.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/qa/test-results.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/qa/visual-qa.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/review/code-review.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/review/standards-review.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/release-notes.md`
|
||||||
|
- Create: `docs/features/GOV-011-runtime-version-series/rollback.md`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/manifest.yaml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: Tasks 1-4 的测试结果、提交 SHA、GitNexus before/impact 与一次性 Schema 例外。
|
||||||
|
- Produces: `governance:finish` 所需的完整、可审查功能档案。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 编写测试计划和开发总结骨架**
|
||||||
|
|
||||||
|
`test-plan.md` 必须明确列出:
|
||||||
|
|
||||||
|
- Node 22.0.0、其他 22.x、21.x、23.x 和无效格式。
|
||||||
|
- MySQL 5.7.0、5.7.41、5.7.42-log、5.6.51、8.0.40 和无效格式。
|
||||||
|
- 字符集、排序规则、库名、时区与固定开发安全边界回归。
|
||||||
|
- HTTP/OpenAPI 无结构变化验证;消息、Redis、Scheduler、Worker 不受影响的边界验证。
|
||||||
|
- 安全验证:无生产数据库访问、无凭据泄漏、固定格式匹配、依赖未变化。
|
||||||
|
- 性能验证:无新增查询/任务/重试,版本匹配为常数时间;无需负载测试的原因和边界。
|
||||||
|
- 全量 `pnpm verify:release`、桌面/移动视觉回归及源码部署验证。
|
||||||
|
|
||||||
|
`development-summary.md` 必须用七个固定二级标题记录原始开发需求、范围变化、实际完成需求、修复缺陷、未完成与延期项、关键提交和验证证据;“没有数据库变更”必须写明原因与边界,不能只写“不适用”。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 执行局部和全量验证**
|
||||||
|
|
||||||
|
依次运行并把命令、退出码、关键计数和日期保存到 `qa/test-results.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:runtime
|
||||||
|
pnpm test:schema-baseline
|
||||||
|
pnpm --filter @a-share-analysis/server exec vitest run libs/infrastructure/src/health-clients.test.ts
|
||||||
|
pnpm test:environment-baseline
|
||||||
|
pnpm check:contracts
|
||||||
|
pnpm lint
|
||||||
|
pnpm format:check
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
pnpm verify:e2e
|
||||||
|
pnpm verify:release
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 除 `pnpm verify:release` 因已授权的 v0.0.8 Schema 基线缺失而失败外,其余命令 PASS;`pnpm check:contracts` 执行后 OpenAPI 和 Web 生成类型没有非预期差异。必须继续手工执行 `verify:release` 在 Schema 失败点之后的构建、E2E 和源码发布门禁。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 完成视觉验证**
|
||||||
|
|
||||||
|
启动 Web/API 的本地开发服务,复用既有 Playwright 桌面与移动视口检查首页健康展示及控制台;GOV-011 不改变页面布局,`qa/visual-qa.md` 仍须保存运行命令、视口、截图路径、控制台结果和“不发生视觉变化”的证据。服务结束后确认没有遗留进程。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 执行 GitNexus 开发后检测与比较**
|
||||||
|
|
||||||
|
先执行 GitNexus detect-changes,保存受影响符号、执行流和风险到 `gitnexus/detected-changes.md`;再运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
npx gitnexus status
|
||||||
|
```
|
||||||
|
|
||||||
|
将开发后节点、关系、流程、工作区摘要写入 `gitnexus/after.md`,把 before/after 差异和以下逐项结论写入 `gitnexus/comparison.md`:HTTP/OpenAPI、Web API、消息、数据库、Redis、API、Scheduler、Worker、部署、测试、文档。若出现计划外 HIGH/CRITICAL 风险或新的执行流,停止发布并回到影响分析。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 记录 Schema 例外门禁证据**
|
||||||
|
|
||||||
|
运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm schema:verify
|
||||||
|
pnpm governance:finish GOV-011 runtime-version-series
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 两者均因 `database/schema-baselines/v0.0.8` 缺失而失败。把完整错误摘要和退出码写入 `qa/test-results.md`,并引用 `schema-baseline-waiver.md`;不得把预期失败写成通过,也不得生成伪造基线。
|
||||||
|
|
||||||
|
随后手工执行 `governance:finish` 在 Schema 失败前要求的治理、MySQL、环境和 Schema 测试,并人工核对全部必备档案、证据章节和 Review 末行。该一次性例外只替代 v0.0.8 基线存在性,不替代任何测试、GitNexus、Review、提交或推送门禁。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 完成两类 Review**
|
||||||
|
|
||||||
|
`review/code-review.md` 先列发现,按严重度记录文件/行、影响和处置;必须包含“安全与残余风险”“性能与资源影响”“GitNexus 与同步修改”。`review/standards-review.md` 逐项核对注释、职责拆分、TDD、规范、历史证据、中文提交、Gitea 推送和一次性例外边界;必须包含“安全、性能与架构检查”“GitNexus 与开发总结检查”。
|
||||||
|
|
||||||
|
只有所有重要问题关闭后,两份报告最后一个非空行才写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
最终结论:passed
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: 提交并推送完整验证档案**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/features/GOV-011-runtime-version-series
|
||||||
|
git commit -m "docs(GOV-011): 完成系列版本验证与审查"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功,远程包含完整功能档案;提交前 GitNexus detect-changes 已保存且实际范围与影响计划一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 发布 v0.0.9 并封存追溯状态
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `VERSION`
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
- Modify: `docs/index.md`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/manifest.yaml`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/development-summary.md`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/release-notes.md`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/qa/test-results.md`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/review/code-review.md`
|
||||||
|
- Modify: `docs/features/GOV-011-runtime-version-series/review/standards-review.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 已通过的代码、测试、Review、GitNexus 证据与用户确认的一次性 Schema 例外。
|
||||||
|
- Produces: 发布提交、Gitea `main` 与带注释标签 `v0.0.9`。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 更新版本和发布文档**
|
||||||
|
|
||||||
|
将 `VERSION` 更新为 `0.0.9`;`CHANGELOG.md` 和 `docs/index.md` 记录 GOV-011 的实际完成范围、Schema 一次性例外和历史证据不改写原则。`manifest.yaml` 更新为 `status: released`、`release: v0.0.9`,并补齐实施计划、测试、开发总结、GitNexus、Review 文件引用。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 执行发布前最终验证**
|
||||||
|
|
||||||
|
Run: `pnpm verify:release`
|
||||||
|
|
||||||
|
Expected: 因已授权的一次性例外,在 v0.0.8 Schema 严格发布基线检查处失败;失败点之前的门禁和手工补跑的后续构建、E2E、源码发布验证必须全部通过,且真实结果写入 QA。
|
||||||
|
|
||||||
|
Run: `git diff --check && git status --short && git log -1 --oneline`
|
||||||
|
|
||||||
|
Expected: 只有预期发布文档变更,格式无错误;提交前再次执行 GitNexus detect-changes。
|
||||||
|
|
||||||
|
按 Task 5 重跑 `pnpm schema:verify` 和 `pnpm governance:finish GOV-011 runtime-version-series`,Expected 仍是已记录的一次性例外失败;确认没有新增或变化的失败原因。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 创建并推送发布封存提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add VERSION CHANGELOG.md docs/index.md docs/features/GOV-011-runtime-version-series
|
||||||
|
git commit -m "chore(GOV-011): 发布 v0.0.9 系列版本约束"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 推送成功,本地 `HEAD`、`origin/main` 和 Gitea `refs/heads/main` SHA 一致。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 创建并精确推送标签**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v0.0.9 -m "发布 v0.0.9:统一 Node 与 MySQL 系列版本约束"
|
||||||
|
git push origin refs/tags/v0.0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Gitea `refs/tags/v0.0.9^{}` 指向发布封存提交;不使用 `git push --tags`,不覆盖未知远程标签。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 最终核对并封存实际 SHA**
|
||||||
|
|
||||||
|
将最终分支提交和标签 SHA 回填到 `development-summary.md` 与 `qa/test-results.md` 时,使用新的中文文档提交并立即推送;如回填会使标签不再指向最终文档提交,则明确记录“标签指向发布封存提交、追溯回填位于后续文档提交”,不得移动已推送标签。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 计划自审
|
||||||
|
|
||||||
|
- 规格覆盖:FR-001 至 FR-005、全部验收标准、安全、性能、历史证据和一次性 Schema 例外均有对应任务。
|
||||||
|
- 影响覆盖:`assertMysql57Metadata` 的 CRITICAL 调用链由 Task 2 完整回归;`MysqlHealthClient` 与 `assertSupportedRuntime` 分别由 Task 3 和 Task 1 覆盖。
|
||||||
|
- 类型一致性:计划只消费既有 `assertSupportedRuntime`、`assertMysql57Metadata`、`MysqlHealthClient.check` 和 `DependencyHealthDto`,没有新增跨任务接口或命名漂移。
|
||||||
|
- 范围控制:不新增依赖、不改 DTO/生成契约、不改数据库结构、不接触生产数据库、不批量改写历史证据。
|
||||||
|
- 占位符检查:未发现待补内容、跨任务省略引用或无验证方式的实现步骤;每个代码行为都有精确测试命令和预期结果。
|
||||||
|
- 回退可行:功能只修改判断、详情文字、注释和文档;出现历史基线失效或错误放行时,可用中文回退提交恢复基线行为,不需要数据回滚。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
id: GOV-011
|
||||||
|
name: Node 与 MySQL 系列版本约束
|
||||||
|
slug: runtime-version-series
|
||||||
|
status: released
|
||||||
|
owner: project
|
||||||
|
created_at: 2026-08-17
|
||||||
|
prd: prd.md
|
||||||
|
requirements: requirements.md
|
||||||
|
design: design.md
|
||||||
|
implementation_plan: implementation-plan.md
|
||||||
|
test_plan: test-plan.md
|
||||||
|
development_summary: development-summary.md
|
||||||
|
schema_baseline_waiver: schema-baseline-waiver.md
|
||||||
|
gitnexus_before: gitnexus/before.md
|
||||||
|
gitnexus_impact: gitnexus/impact-plan.md
|
||||||
|
gitnexus_detected_changes: gitnexus/detected-changes.md
|
||||||
|
gitnexus_after: gitnexus/after.md
|
||||||
|
gitnexus_comparison: gitnexus/comparison.md
|
||||||
|
code_review: review/code-review.md
|
||||||
|
standards_review: review/standards-review.md
|
||||||
|
release: v0.0.9
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Node 与 MySQL 系列版本约束 PRD
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 状态:设计复核
|
||||||
|
- 日期:2026-08-17
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
项目目标基线一直是 Node.js 22.x 与 MySQL 5.7.x,但部分实现、错误提示和现行规范把实际开发环境的 MySQL 5.7.42 写成了唯一允许的补丁版本。Node 运行时已经按 22 主版本校验,只是历史验证记录会显示实际使用的 22.11.0 或 22.22.0。用户明确说明本项目主要由本人部署,不需要锁定某个补丁版本,希望现行口径统一为系列版本。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- Node.js 接受全部格式合法的 22.x,拒绝其他主版本。
|
||||||
|
- MySQL 客户端、mysqldump、服务端和运行时健康检查接受全部格式合法的 5.7.x,拒绝 5.6.x、8.x 及其他系列。
|
||||||
|
- 字符集 `utf8mb4`、排序规则 `utf8mb4_general_ci`、会话时区 `+08:00`、固定开发库和主机白名单继续严格校验。
|
||||||
|
- 健康结果展示服务端返回的实际版本,不再硬编码 5.7.42。
|
||||||
|
- 当前规范统一使用系列口径,历史 QA、Schema 清单和已发布档案继续保留当时真实补丁版本。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不升级 Node、MySQL、pnpm、数据库驱动或 ORM。
|
||||||
|
- 不修改数据库结构、数据、账号、网络或部署方式。
|
||||||
|
- 不支持 Node 23、MySQL 8.x、MariaDB 或其他“兼容 MySQL”数据库。
|
||||||
|
- 不为各补丁版本建立兼容矩阵或自动安装运行时。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. Node 22.0.0、22.11.0 和带预发布/构建后缀的合法 22.x 版本通过;21.x、23.x 和无效格式拒绝。
|
||||||
|
2. MySQL 5.7.0、5.7.41、5.7.42-log 等合法 5.7.x 通过;5.6.x、8.0.x 和无效格式拒绝。
|
||||||
|
3. MySQL 5.7.x 即使版本通过,字符集、排序规则、库名或时区错误仍拒绝。
|
||||||
|
4. 既有 v0.0.5 至 v0.0.7 Schema 历史清单继续验证,不被改写。
|
||||||
|
5. 健康详情包含实际服务端版本;不泄漏连接地址、账号或密码。
|
||||||
|
6. 全量测试、构建、Playwright、源码发布门禁和两类 Review 通过。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# GOV-011 测试与质量证据
|
||||||
|
|
||||||
|
- 验证提交:`cce77aa`
|
||||||
|
- 验证日期:2026-08-17
|
||||||
|
- 运行时:Node.js 22.11.0、pnpm 10.25.0
|
||||||
|
|
||||||
|
## RED-GREEN 证据
|
||||||
|
|
||||||
|
| 行为 | RED | GREEN |
|
||||||
|
|---|---|---|
|
||||||
|
| Schema 接受完整 5.7.x | `5.7.0` 被旧实现以“必须为 5.7.42”拒绝 | 系列内/外定向测试 2/2 通过 |
|
||||||
|
| 健康检查接受 5.7.41-log | 旧实现拒绝并输出 5.7.42 文案 | 健康客户端 5/5 通过,详情为 `MySQL 5.7.41 / ...` |
|
||||||
|
| 当前文档采用系列口径 | 架构/规范 0/2,部署文档测试 9/10 | MySQL 2/2、环境 10/10、部署 6/6 |
|
||||||
|
|
||||||
|
## 自动化验证
|
||||||
|
|
||||||
|
| 命令或测试组 | 结果 | 关键证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| `pnpm check:runtime` | passed | Node.js 22.11.0、pnpm 10.25.0 |
|
||||||
|
| `pnpm check:contracts` | passed | OpenAPI 与 Web 生成类型无漂移,项目边界通过 |
|
||||||
|
| `pnpm lint` | passed | Web/Server ESLint 无错误 |
|
||||||
|
| `pnpm format:check` | passed | 全仓 Prettier 通过 |
|
||||||
|
| `pnpm typecheck` | passed | Web、Vite 和 Server TypeScript 通过 |
|
||||||
|
| runtime | 3/3 | 22.x 系列与相邻主版本 |
|
||||||
|
| governance | 10/10 | 功能档案和 Review 门禁 |
|
||||||
|
| MySQL 规范 | 2/2 | 当前架构/规范使用 5.7.x |
|
||||||
|
| 环境 | 10/10 | 部署手册使用 5.7.x,其他环境边界保持 |
|
||||||
|
| Schema(跳过严格发布基线) | 28/28 | 生成、验证、历史 manifest 和字符规则回归 |
|
||||||
|
| contracts | 3/3 | 生成契约跟踪与漂移阻断 |
|
||||||
|
| boundaries | 16/16 | 前后端与 HTTP 边界 |
|
||||||
|
| deployment | 6/6 | 唯一 Ubuntu 源码发布边界 |
|
||||||
|
| Web | 11/11 | 请求、adapter 与外壳 |
|
||||||
|
| Server | 30/30 | API、配置、基础设施和进程 |
|
||||||
|
| API 预检 | 2/2 | Redis 可达/不可达分支 |
|
||||||
|
| `pnpm build` | passed | Web、API、Scheduler、Worker 构建成功 |
|
||||||
|
| `pnpm verify:e2e` | 4/4 | Chromium 桌面/移动页面和健康 API |
|
||||||
|
| `pnpm verify:source` | passed | 构建产物、systemd、Nginx 一致 |
|
||||||
|
|
||||||
|
## 一次性例外失败证据
|
||||||
|
|
||||||
|
`pnpm verify:release` 退出码非 0。它依次通过 runtime、契约、lint、格式、类型以及 Schema 之前的测试,随后在 Schema 第 28 个严格发布基线用例失败;实际错误为缺少 `database/schema-baselines/v0.0.8`。Schema 总计 29 项,其中 28 项通过、1 项为该已知失败。
|
||||||
|
|
||||||
|
`pnpm schema:verify` 在直接目录校验处失败;`pnpm governance:finish GOV-011 runtime-version-series` 在其前置 `test:schema-baseline` 的严格发布基线用例处失败。两者根因都是同一个 v0.0.8 目录缺失条件。原因、用户授权和禁止伪造边界见 `schema-baseline-waiver.md`;其余等价门禁已逐项执行并保存上表证据。
|
||||||
|
|
||||||
|
## 安全证据
|
||||||
|
|
||||||
|
- `pnpm audit --prod --registry=https://registry.npmjs.org/`:`No known vulnerabilities found`。
|
||||||
|
- `redis-cli -h 127.0.0.1 -p 6379 ping`:`PONG`,只验证本机开发 Redis。
|
||||||
|
- 构建产物扫描只命中 `MYSQL_PASSWORD`、`AI_API_KEY` 等配置键和必填错误文案,没有真实值、连接串或 Authorization 头。
|
||||||
|
- 本次没有新增依赖、SQL、命令执行、数据库写入、生产连接或外部网络请求。
|
||||||
|
- 健康详情只返回三段版本、库名和时区,不返回原始后缀、地址、账号或密码。
|
||||||
|
|
||||||
|
## 性能证据
|
||||||
|
|
||||||
|
- Web JS:163908 字节;CSS:805 字节,与 v0.0.8 记录一致。
|
||||||
|
- 健康检查仍为一次元数据查询,连接池上限 2;无重试、扫描、缓存或消息增量。
|
||||||
|
- Schema 与 runtime 判断只在启动/治理阶段执行常数时间正则匹配,不进入行情请求热路径。
|
||||||
|
|
||||||
|
## GitNexus 证据
|
||||||
|
|
||||||
|
- 发布后 after:2090 节点、2767 关系、49 集群、50 流程,索引 `88e2fdf` 为 up-to-date。
|
||||||
|
- compare:23 文件、70 符号、11 流程、HIGH;运行影响仅在既有 Schema 与 API 健康路径。
|
||||||
|
- `AGENTS.md` 的 GitNexus 计数在实现和发布两次 `analyze` 中由 2004/2681 更新为 2015/2692,再更新为 2090/2767。
|
||||||
|
|
||||||
|
## 发布引用
|
||||||
|
|
||||||
|
- 发布提交:`88e2fdffc8c71dd682405c148a81953a8dcee9d6`。
|
||||||
|
- Gitea `refs/heads/main`:发布时与上述提交一致。
|
||||||
|
- 带注释标签对象:`4d1553b68587a6218416bf0279494d720bb37161`。
|
||||||
|
- Gitea `refs/tags/v0.0.9^{}`:`88e2fdffc8c71dd682405c148a81953a8dcee9d6`。
|
||||||
|
- 标签只精确推送一次,未使用批量标签推送、强推或移动标签。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# GOV-011 视觉回归记录
|
||||||
|
|
||||||
|
- 页面:应用工程基础外壳
|
||||||
|
- 变更类型:后端版本判断、测试、注释和文档;无页面样式或交互修改
|
||||||
|
- 结论:无可观察视觉回归
|
||||||
|
|
||||||
|
## 桌面
|
||||||
|
|
||||||
|
- Chromium 视口:1440×900
|
||||||
|
- 截图:[app-shell-desktop-1440x900-cce77aa.png](/Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis/docs/features/GOV-011-runtime-version-series/screenshots/iterations/app-shell-desktop-1440x900-cce77aa.png)
|
||||||
|
- 检查:`main` 可见,标题正常,无横向溢出,控制台 warning/error 为空。
|
||||||
|
|
||||||
|
## 移动
|
||||||
|
|
||||||
|
- Chromium 视口:390×844
|
||||||
|
- 截图:[app-shell-mobile-390x844-cce77aa.png](/Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis/docs/features/GOV-011-runtime-version-series/screenshots/iterations/app-shell-mobile-390x844-cce77aa.png)
|
||||||
|
- 检查:标题与主内容正常换行和留白,无横向溢出、遮挡或裁切,控制台 warning/error 为空。
|
||||||
|
|
||||||
|
## 自动化证据
|
||||||
|
|
||||||
|
`pnpm verify:e2e` 在 desktop-chromium 与 mobile-chromium 共执行 4 项,全部通过;覆盖页面可见性、横向溢出、运行时错误和同源 `/api/v1/health` 契约。截图生成后测试 API 与 Vite 服务均通过 SIGINT 关闭,端口 3000/5173 无遗留监听。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# GOV-011 发布说明
|
||||||
|
|
||||||
|
- 发布版本:v0.0.9
|
||||||
|
- 状态:已发布
|
||||||
|
- 发布日期:2026-08-17
|
||||||
|
- 发布提交:`88e2fdffc8c71dd682405c148a81953a8dcee9d6`
|
||||||
|
- 标签:`v0.0.9`(带注释标签,解引用后指向发布提交)
|
||||||
|
|
||||||
|
## 调整
|
||||||
|
|
||||||
|
- Node.js 运行时约束明确为完整 22.x,不绑定 minor 或 patch。
|
||||||
|
- MySQL 客户端、mysqldump、服务端和 API 健康检查统一接受完整 5.7.x,不设置最低 patch。
|
||||||
|
- MySQL 健康详情显示服务端实际三段版本,不再硬编码 5.7.42。
|
||||||
|
- 系统架构、代码开发规范、本地开发和 Ubuntu 源码部署手册统一使用系列版本口径。
|
||||||
|
|
||||||
|
## 保持不变
|
||||||
|
|
||||||
|
- `utf8mb4`、`utf8mb4_general_ci`、`+08:00`、固定开发库和主机白名单继续严格校验。
|
||||||
|
- HTTP/OpenAPI 响应结构、Web API、Redis、RocketMQ、Scheduler、Worker、数据库 Schema 和源码部署拓扑不变。
|
||||||
|
- 历史 QA、Schema 清单、CHANGELOG 历史版本和已发布功能档案继续记录当时真实 patch。
|
||||||
|
|
||||||
|
## 已知例外
|
||||||
|
|
||||||
|
v0.0.8 没有开发库 Schema 基线。用户确认开发库为空并授权 GOV-011 一次性跳过;仓库没有伪造基线或增加通用绕过开关。该例外导致 `verify:release`、`schema:verify` 和 `governance:finish` 在同一基线存在性检查失败,其他门禁已逐项执行并留证。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Node 与 MySQL 系列版本约束需求
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
- FR-001:运行时门禁必须以主版本 22 判断 Node.js,不绑定 minor 或 patch。
|
||||||
|
- FR-002:Schema 生成与验证必须以 major/minor 5.7 判断 mysql 客户端、mysqldump 客户端和 MySQL 服务端,不绑定 patch。
|
||||||
|
- FR-003:API MySQL 健康检查必须接受任意 5.7.x,并在成功详情中显示实际版本。
|
||||||
|
- FR-004:MySQL 5.7.x 系列校验不得放宽数据库名、字符集、排序规则、时区或开发环境安全边界。
|
||||||
|
- FR-005:现行架构、开发规范和部署手册必须使用 Node.js 22.x、MySQL 5.7.x;历史证据中的实际版本不批量改写。
|
||||||
|
|
||||||
|
## 质量需求
|
||||||
|
|
||||||
|
- 必须测试系列内至少两个不同补丁版本和系列外上下边界。
|
||||||
|
- 必须遵循 RED → GREEN:先让 5.7.41 被精确 5.7.42 校验拒绝,再实施系列校验。
|
||||||
|
- `assertMysql57Metadata` 的 GitNexus 风险为 CRITICAL,必须完整回归 Schema 生成、历史验证、当前发布基线和治理启动相关测试。
|
||||||
|
- 不新增依赖,不增加生产请求、数据库查询或后台任务。
|
||||||
|
|
||||||
|
## 安全与性能
|
||||||
|
|
||||||
|
- 版本字符串只做固定格式匹配,不执行命令、不拼接 SQL。
|
||||||
|
- 数据库访问仍限于既有只读元数据查询;本功能不连接生产数据库。
|
||||||
|
- 运行时只增加常数时间字符串判断,不产生可测量的吞吐或内存影响。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# GOV-011 代码 Review
|
||||||
|
|
||||||
|
- reviewer_type:self
|
||||||
|
- reviewer:当前实现代理结构化自审
|
||||||
|
- 基线:`v0.0.8` / `9e85809`
|
||||||
|
- 审查实现:`cce77aa`
|
||||||
|
- 日期:2026-08-17
|
||||||
|
|
||||||
|
## 审查范围
|
||||||
|
|
||||||
|
审查 Node 特征测试、Schema 元数据校验、MySQL 健康客户端、治理测试、现行架构/规范/部署文档、GitNexus 影响和一次性 Schema 例外。重点检查版本格式、错误边界、历史清单兼容、健康信息泄漏和门禁真实性。
|
||||||
|
|
||||||
|
## 发现
|
||||||
|
|
||||||
|
### 已关闭问题
|
||||||
|
|
||||||
|
| 严重程度 | 位置 | 问题 | 处置 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Important | `tools/database/schema-baseline.test.mjs` 历史清单篡改测试 | 完整回归仍断言旧的“服务端必须为 5.7.42”文案,导致生产实现转为系列口径后测试失败。 | 更新为 `5.7.x` 断言;排除已授权发布基线用例后 Schema 28/28。 |
|
||||||
|
| Minor | `implementation-plan.md` 全量门禁预期 | 原计划把 `verify:release` 写为 PASS,但该命令包含必然触发一次性 Schema 例外的测试。 | 修正文档为真实失败并要求手工执行其余等价门禁。 |
|
||||||
|
|
||||||
|
### 未发现问题
|
||||||
|
|
||||||
|
未发现未关闭的 Critical、Important 或阻塞性 Minor。服务端正则只接受 `5.7.<数字>` 与 `-`/`+` 后缀;5.6、8.0、无 patch 和文字 patch 均拒绝。字符集、排序规则、库名、时区和生产数据库边界没有放宽。
|
||||||
|
|
||||||
|
## 测试缺口
|
||||||
|
|
||||||
|
没有针对每个 MySQL 5.7 patch 建立真实数据库兼容矩阵;这是用户明确接受的系列版本策略。本次使用纯函数、固定元数据和现有集成测试验证判断边界,不证明第三方数据库每个 patch 都没有厂商缺陷。首次真实迁移和查询仍必须在目标 5.7.x 环境验证。
|
||||||
|
|
||||||
|
## 安全与残余风险
|
||||||
|
|
||||||
|
- 版本字符串不执行、不进入 SQL;健康详情不回显原始后缀、地址、账号或密码。
|
||||||
|
- Schema 入口仍固定 development、开发库和主机白名单;本次未连接生产数据库。
|
||||||
|
- 残余风险一是不同 5.7 patch 的已知缺陷,二是 v0.0.8 缺少可验证 Schema 基线。前者由真实部署验证控制,后者由用户授权的一次性例外承担。
|
||||||
|
|
||||||
|
## 性能与资源影响
|
||||||
|
|
||||||
|
- 只增加常数时间正则捕获,没有新增查询、网络、重试、缓存、任务或依赖。
|
||||||
|
- 健康查询次数和连接池上限不变;Web 构建体积与 v0.0.8 一致。
|
||||||
|
- 不进入行情采集、分析、Redis、RocketMQ 或图表热路径,无需负载测试。
|
||||||
|
|
||||||
|
## GitNexus 与同步修改
|
||||||
|
|
||||||
|
开发前 `assertMysql57Metadata` 为 CRITICAL,5 个直接调用方、10 个受影响符号、5 组流程;`MysqlHealthClient` 为 LOW。开发后相对 v0.0.8 为 HIGH、23 文件、70 符号、11 流程,运行影响均属于计划中的 Schema 与 API 健康路径。HTTP/OpenAPI、Web API、消息、数据库 Schema、Redis、Scheduler、Worker 和部署拓扑没有计划外修改。
|
||||||
|
|
||||||
|
## v0.0.9 发布封存复核
|
||||||
|
|
||||||
|
- 发布只增加版本、CHANGELOG、索引和功能状态,不修改运行时判断、健康查询或数据库边界。
|
||||||
|
- 一次性 Schema 例外、HIGH GitNexus 风险和手工等价门禁证据均保留,没有改写为全量门禁通过。
|
||||||
|
- 发布标签必须指向通过上述 Review 的封存提交,并与 Gitea 远程引用核对。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# GOV-011 开发规范符合性 Review
|
||||||
|
|
||||||
|
- reviewer_type:self
|
||||||
|
- reviewer:当前实现代理结构化自审
|
||||||
|
- 审查提交:`cce77aa`
|
||||||
|
- 日期:2026-08-17
|
||||||
|
|
||||||
|
## 检查结果
|
||||||
|
|
||||||
|
- [x] PRD、需求、设计、实施计划、测试计划、开发总结、QA、GitNexus、Review、发布与回退资料齐全。
|
||||||
|
- [x] 修改关键符号前执行 GitNexus 上游影响分析,提交前执行 detect-changes。
|
||||||
|
- [x] HIGH/CRITICAL 风险已向用户说明,并以完整 Schema 与健康回归控制。
|
||||||
|
- [x] 行为修改遵循 RED-GREEN,测试失败原因来自旧实现而非语法或夹具。
|
||||||
|
- [x] 关键版本口径和安全边界使用准确中文注释,没有逐行复述。
|
||||||
|
- [x] 未新增依赖、预测性抽象、空分层或过度文件拆分。
|
||||||
|
- [x] 没有修改前端 Axios/API 分域、后端业务模块分层或 OpenAPI DTO。
|
||||||
|
- [x] 所有阶段提交使用中文 Conventional Commit 和 GOV-011,并立即推送 Gitea。
|
||||||
|
- [x] 没有使用 GitHub、强推、生产数据库或真实凭据。
|
||||||
|
- [x] 历史 QA、Schema 清单和已发布档案保持原样。
|
||||||
|
|
||||||
|
## 不符合项与处置
|
||||||
|
|
||||||
|
完成门禁无法通过 v0.0.8 Schema 基线存在性校验。该项不是静默忽略:用户在开发前明确确认当前开发库为空并要求本次跳过,仓库保存 `schema-baseline-waiver.md`,实际失败命令和风险写入 QA。没有修改治理工具,也没有伪造 `not_applicable`、`captured_empty` 或空 SQL。除此之外未发现未处置的不符合项。
|
||||||
|
|
||||||
|
## 安全、性能与架构检查
|
||||||
|
|
||||||
|
安全边界保持固定开发环境、库名、主机白名单、字符集、排序规则和时区;健康输出已脱敏,依赖审计 0 漏洞。性能只增加常数时间字符串匹配,查询、连接池、构建体积和执行流数量没有增加。架构仍为 Vue 3 Web 与 NestJS API/Scheduler/Worker 独立项目、Ubuntu 源码部署和外部 MySQL/Redis/RocketMQ。
|
||||||
|
|
||||||
|
## GitNexus 与开发总结检查
|
||||||
|
|
||||||
|
before、impact-plan、detected-changes、after、comparison 互相对应;最终索引为 2015 节点、2692 关系、50 流程。开发总结包含七个固定章节,明确原始需求、范围变化、实际完成、缺陷、延期、提交和验证证据;HIGH 风险与一次性 Schema 例外均有具体边界,不以空占位通过门禁。
|
||||||
|
|
||||||
|
## v0.0.9 发布封存复核
|
||||||
|
|
||||||
|
- VERSION、CHANGELOG、文档索引、manifest、开发总结和发布说明的 v0.0.9 状态必须一致。
|
||||||
|
- 发布提交和标签分别精确推送;禁止强推、批量推送标签或移动已发布标签。
|
||||||
|
- 历史 v0.0.8 的 5.7.42 事实不因当前系列口径反向改写。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# GOV-011 回退说明
|
||||||
|
|
||||||
|
GOV-011 不修改数据库结构、迁移或数据,不写 Redis,不操作 RocketMQ,也不修改生产配置。回退只涉及版本判断、健康详情、测试、注释和当前规范文档。
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
- 合法 MySQL 5.7.x 被错误拒绝,或 5.6/8.x 被错误放行。
|
||||||
|
- 历史 captured/captured_empty manifest 无法验证。
|
||||||
|
- 健康接口泄漏原始版本后缀或返回错误 patch。
|
||||||
|
- 系列判断导致 Schema 生成、治理启动或 API 启动出现无法修复的回归。
|
||||||
|
|
||||||
|
## 回退步骤
|
||||||
|
|
||||||
|
1. 使用新的中文 Conventional Commit 恢复 `assertMysql57Metadata` 和 `MysqlHealthClient` 到上一已验证行为;禁止重写 Gitea 历史、强推或移动已发布标签。
|
||||||
|
2. 同步恢复对应测试、代码注释和当前架构/部署文档,避免实现与规范不一致。
|
||||||
|
3. 不改写已经发布的 GOV-011 档案;通过后续回退记录说明原因、范围和提交。
|
||||||
|
4. 因无数据库结构或数据变化,不执行数据库回滚。
|
||||||
|
|
||||||
|
## 回退验证
|
||||||
|
|
||||||
|
- 重新执行 runtime、Schema、MySQL 规范、环境、Server 健康、类型和构建测试。
|
||||||
|
- 执行 OpenAPI 契约、Playwright 双视口和源码发布验证。
|
||||||
|
- 运行 GitNexus impact、detect-changes 与 analyze,记录回退实际流程影响。
|
||||||
|
- v0.0.8 Schema 一次性例外仍只作为历史事实存在,不因代码回退生成伪造基线。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# v0.0.8 Schema 基线一次性例外
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 日期:2026-08-17
|
||||||
|
- 适用范围:仅 GOV-011 开发启动
|
||||||
|
|
||||||
|
## 事实记录
|
||||||
|
|
||||||
|
按常规流程执行 `pnpm schema:baseline` 时,本机因不存在 MySQL 5.7 `mysql`/`mysqldump` 客户端而在执行 `mysql --version` 前失败,未连接数据库、未生成目录或半成品;随后 `schema:verify` 因 `database/schema-baselines/v0.0.8` 不存在而按设计阻断。
|
||||||
|
|
||||||
|
用户确认当前开发库没有业务结构,并明确要求本次先跳过备份。GOV-011 只调整 Node/MySQL 版本系列判断,不创建表、迁移或数据,不读取生产数据库。
|
||||||
|
|
||||||
|
## 处理决定
|
||||||
|
|
||||||
|
- 不伪造 `not_applicable`、`captured_empty`、空 SQL、版本信息或校验摘要。
|
||||||
|
- 不修改 `governance:start` 或 Schema 工具增加通用跳过开关。
|
||||||
|
- 手工保存与 `governance:start` 同等的 GitNexus before/impact 文档,并在 GOV-011 Review 中核对本例外。
|
||||||
|
- 本例外不自动适用于后续功能;下一次需要 Schema 基线时仍应提供可验证的 MySQL 5.7.x 客户端或由用户再次明确决定。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
缺少 v0.0.8 Schema 基线意味着无法用仓库证据证明当时开发库为空。该风险由用户接受;GOV-011 不修改数据库,不能借本例外执行任何 Schema 或数据写入。
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
# GOV-011 测试计划
|
||||||
|
|
||||||
|
- 功能编号:GOV-011
|
||||||
|
- 验证环境:Node.js 22.11.0、pnpm 10.25.0、macOS 开发机
|
||||||
|
- 数据库边界:不连接、探测或读取生产数据库;本次不执行真实 Schema 导出
|
||||||
|
|
||||||
|
## 1. Node.js 系列验证
|
||||||
|
|
||||||
|
- 接受 `22.0.0`、`22.11.0` 和带预发布/构建后缀的合法 22.x。
|
||||||
|
- 拒绝 21.x、23.x、空字符串、`v22` 和其他无法解析格式。
|
||||||
|
- pnpm 继续只接受 10.x,不因 Node 约束调整而放宽。
|
||||||
|
|
||||||
|
## 2. Schema 元数据验证
|
||||||
|
|
||||||
|
- 接受 MySQL 服务端 `5.7.0`、`5.7.41`、`5.7.42-log`。
|
||||||
|
- 拒绝 `5.6.51`、`8.0.40`、`5.7`、`5.7.x` 和空字符串。
|
||||||
|
- mysql 与 mysqldump 客户端继续要求合法 5.7.x。
|
||||||
|
- 服务端版本通过后,字符集仍必须为 `utf8mb4`,排序规则仍必须为 `utf8mb4_general_ci`。
|
||||||
|
- 回归 captured、captured_empty、not_applicable、历史 manifest、生成入口和发布门禁调用链。
|
||||||
|
- 因用户授权的 v0.0.8 基线一次性例外,严格发布基线用例预期只在目录缺失处失败;使用 `--test-skip-pattern='严格发布门禁'` 验证其余 28 项全部通过。
|
||||||
|
|
||||||
|
## 3. API 健康检查验证
|
||||||
|
|
||||||
|
- `5.7.41-log` 返回 `MySQL 5.7.41 / <数据库> / +08:00`,证明 patch 来自服务端元数据而非硬编码。
|
||||||
|
- 拒绝 5.6.x、8.x 和无效版本格式。
|
||||||
|
- 拒绝错误库名、`utf8`、`utf8mb4_0900_ai_ci` 和 `SYSTEM` 时区。
|
||||||
|
- 保持固定只读元数据查询、健康 DTO 结构和生产库名按当前配置校验。
|
||||||
|
- 执行 Server 全量测试、类型检查和 API/Scheduler/Worker 三进程构建。
|
||||||
|
|
||||||
|
## 4. 规范与部署验证
|
||||||
|
|
||||||
|
- 当前系统架构、代码开发规范、本地开发和 Ubuntu 源码部署文档必须写明 `MySQL 5.7.x`。
|
||||||
|
- 上述四份当前文档不得再出现 `MySQL 5.7.42`;历史 QA、Schema 清单、CHANGELOG 历史版本和已发布功能档案不参与该否定扫描。
|
||||||
|
- Node.js 22.x、pnpm 10.25.0、Ubuntu 源码部署、systemd、Nginx、Redis 和 RocketMQ 约束保持不变。
|
||||||
|
|
||||||
|
## 安全验证
|
||||||
|
|
||||||
|
- 版本字符串仅进入固定正则,不执行命令、不拼接 SQL。
|
||||||
|
- MySQL 健康错误不回显服务端原始附加文本、连接地址、账号或密码。
|
||||||
|
- Schema 测试使用固定夹具和依赖注入,不连接真实数据库;本功能不执行 `schema:baseline`。
|
||||||
|
- `pnpm audit --prod --registry=https://registry.npmjs.org/` 必须为 0 已知漏洞。
|
||||||
|
- 扫描 Web/Server 构建产物中的凭据模式;配置键名允许存在,真实值、连接串和授权头不得存在。
|
||||||
|
- `git diff --check`、GitNexus detect-changes 和 Review 核对没有生产数据、真实 `.env` 或凭据进入提交。
|
||||||
|
|
||||||
|
## 性能验证
|
||||||
|
|
||||||
|
- 版本解析为启动/治理阶段常数时间正则匹配,不增加数据库查询、网络调用、重试、缓存或消息操作。
|
||||||
|
- 健康检查继续只执行一次既有元数据查询,连接池上限仍为 2。
|
||||||
|
- Web 页面和依赖未变化,生产 JS/CSS 体积应与 v0.0.8 基线一致。
|
||||||
|
- 本功能不改变行情计算、数据库扫描、Redis 热点、RocketMQ 吞吐、Worker 并发或图表渲染,因此不执行负载测试;后续触及这些路径的业务功能必须重新设定性能目标。
|
||||||
|
|
||||||
|
## 7. 全量与视觉验证
|
||||||
|
|
||||||
|
- 执行 runtime、governance、MySQL、环境、Schema、契约、边界、部署、Web 和 Server 测试。
|
||||||
|
- 执行 OpenAPI 重新生成、lint、格式、类型检查、Web/Server 构建和源码发布验证。
|
||||||
|
- Playwright 在 Chromium 1440×900 与 390×844 下验证页面、同源健康 API、横向溢出和控制台。
|
||||||
|
- 保存桌面/移动截图并人工检查无重叠、裁切或空白渲染回归。
|
||||||
|
- 真实执行 `pnpm verify:release`、`pnpm schema:verify` 和 `pnpm governance:finish GOV-011 runtime-version-series`;三者因同一个已授权 v0.0.8 基线缺失条件失败,必须保存失败证据并手工执行其余等价门禁。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 纯 Node.js 开发库 Schema 导出器设计
|
||||||
|
|
||||||
|
## 1. 架构
|
||||||
|
|
||||||
|
保留 `tools/database/schema-baseline.mjs` 作为治理入口,继续负责固定配置、发布版本、产物构建、历史验证、原子持久化和远程发布核对。新增 `tools/database/mysql2-schema-exporter.mjs`,集中负责连接、元数据读取、`SHOW CREATE`、依赖排序和确定性 SQL 渲染,不把一次业务流程拆成过多小文件。
|
||||||
|
|
||||||
|
```text
|
||||||
|
development 配置
|
||||||
|
-> schema-baseline 安全边界
|
||||||
|
-> mysql2-schema-exporter
|
||||||
|
-> MySQL 5.7 元数据与 SHOW CREATE
|
||||||
|
-> 确定性 schema.sql
|
||||||
|
-> manifest.yaml + checksum.sha256
|
||||||
|
-> 原子版本目录
|
||||||
|
```
|
||||||
|
|
||||||
|
根工作区直接声明 `mysql2`,使治理工具拥有明确依赖。`runBaselineGeneration` 和 CLI 入口改为异步,外部 npm 命令保持不变。
|
||||||
|
|
||||||
|
## 2. format v3 与历史兼容
|
||||||
|
|
||||||
|
format v3 使用独立字段记录 Node 导出器名称、导出器版本、数据库驱动名称、驱动版本和 MySQL 服务端版本,不复用 v1/v2 的 `mysql_client_version` 或 `mysqldump_client_version` 含义。
|
||||||
|
|
||||||
|
验证器按 `format_version` 分支:v1/v2 保留现有字段和校验规则;v3 校验新字段。既有目录不得自动升级、格式化或重写。`captured`、`captured_empty` 和 `not_applicable` 状态继续保留。
|
||||||
|
|
||||||
|
## 3. 对象导出与顺序
|
||||||
|
|
||||||
|
导出器先从 `information_schema` 获取固定库的对象清单和依赖,再对每个对象执行参数受控的 `SHOW CREATE`:
|
||||||
|
|
||||||
|
1. 基础表按名称排序,保留列、索引、外键、表选项和注释。
|
||||||
|
2. 存储过程与函数按类型和名称排序。
|
||||||
|
3. 视图依据库内依赖拓扑排序;循环或未知依赖失败。
|
||||||
|
4. 触发器按所属表、时机、事件和执行顺序排序。
|
||||||
|
5. Event Scheduler 事件按名称排序。
|
||||||
|
|
||||||
|
例程、触发器和事件使用稳定 `DELIMITER`。标识符只能来自元数据,并统一转义反引号。导出结果删除 `DEFINER`、当前 `AUTO_INCREMENT` 值、时间、主机、用户名和客户端噪声;同一结构必须产生逐字节一致结果。
|
||||||
|
|
||||||
|
## 4. 安全策略与评估
|
||||||
|
|
||||||
|
导出前后分别计算对象摘要,包含对象类型、名称、依赖和定义可观察版本;摘要不一致表示并发 DDL,命令失败。任何对象无法 `SHOW CREATE`、结果为空或类型未知时禁止跳过。
|
||||||
|
|
||||||
|
连接只允许固定 development 边界,禁用多语句,使用单连接、超时和对象数量限制。工具只查询元数据和 `SHOW CREATE`,不查询业务表、不锁表、不执行 DDL/DML。密码只存在于内存配置,并在所有路径通过 `finally` 关闭连接。
|
||||||
|
|
||||||
|
现有 SQL 安全扫描继续拒绝顶层数据操作、连接串、疑似凭据和残留 `DEFINER`;对象体内部合法 DML 通过分隔符解析保留。
|
||||||
|
|
||||||
|
## 5. 性能目标与评估
|
||||||
|
|
||||||
|
Schema 备份不在在线请求路径。使用单连接和有限并发,目标中小型开发库 30 秒内完成;连接、查询、总时长、对象数量或响应大小超过保护值时失败。
|
||||||
|
|
||||||
|
产物先写入版本父目录中的临时目录,经过 SQL 扫描、对象计数、manifest 和 SHA-256 一致性校验后原子重命名。失败时删除临时目录,既有版本目录禁止覆盖。
|
||||||
|
|
||||||
|
## 6. 异常恢复与实施迁移
|
||||||
|
|
||||||
|
GOV-012 因缺少 `v0.0.9` 基线使用用户授权的一次性人工启动记录。实现通过后已使用 development 配置连接 MySQL 5.7.x,生成并推送 `v0.0.9` format v3 `captured_empty` 基线;随后重新执行自动 `governance:start`,证明后续开发不再依赖本机 MySQL CLI。MySQL 5.7.x 缺少 `VIEW_TABLE_USAGE` 时,导出器回退读取 `information_schema.VIEWS`,其余查询错误不降级。
|
||||||
|
|
||||||
|
## 7. 依赖评估
|
||||||
|
|
||||||
|
选择已有技术栈中的 `mysql2`,避免引入不透明的第三方 dump 包。替代方案包括安装 MySQL 5.7 客户端或远程执行 `mysqldump`,前者在现代 macOS 上难以维护,后者增加 SSH、凭据和远程工具版本风险。退出时可替换 exporter 端口实现,但 format v3 历史基线验证必须永久保留。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# GOV-012 开发总结
|
||||||
|
|
||||||
|
## 原始开发需求
|
||||||
|
|
||||||
|
将 Schema 版本基线从本机 `mysql`/`mysqldump` 子进程迁移为 Node.js 22.x 与 mysql2 直接读取 MySQL 5.7.x,保留历史格式兼容、恢复开发门禁,并满足安全、性能、可追溯和 Gitea 推送规范。
|
||||||
|
|
||||||
|
## 需求与范围变化
|
||||||
|
|
||||||
|
- 原计划使用 MySQL 5.7 `VIEW_TABLE_USAGE` 读取视图依赖;真实 5.7.42 不提供该表,增加仅针对 ER_UNKNOWN_TABLE 的 `information_schema.VIEWS` 定义解析回退。
|
||||||
|
- 原计划空库 exporter 返回会话 SET;实际 format v3 空库必须返回空 SQL,修正为 `captured_empty`。
|
||||||
|
- GOV-012 不接入 HTTP/OpenAPI、Web、Redis、RocketMQ、API、Scheduler 或 Worker 运行时。
|
||||||
|
|
||||||
|
## 实际完成需求
|
||||||
|
|
||||||
|
- 新增 mysql2 单连接导出器:元数据校验、目录读取、视图排序、六类 `SHOW CREATE`、确定性渲染、并发漂移检测、超时/大小/对象上限和错误脱敏。
|
||||||
|
- `schema-baseline.mjs` 支持 format v3、异步 exporter 注入、原子文件操作故障注入、CLI 异步入口,并永久只读兼容 v1/v2。
|
||||||
|
- 更新当前开发规范、开发流程、README、设计和测试计划;真实开发库生成并推送 `v0.0.9` `captured_empty` 清单。
|
||||||
|
|
||||||
|
## 修复缺陷
|
||||||
|
|
||||||
|
- 拒绝 MariaDB 伪装版本。
|
||||||
|
- 严格校验触发器时机、事件和执行顺序。
|
||||||
|
- 防止目录、元数据和 SHOW CREATE 直接 API 泄露数据库错误。
|
||||||
|
- 保留 `EXPORT_TIMEOUT` 语义,避免包装器降级为普通查询错误。
|
||||||
|
- 防止全局正则改写例程字符串中的 `AUTO_INCREMENT`,并拒绝导出定义中的主机信息。
|
||||||
|
- 兼容真实 MySQL 5.7.42 缺少 `VIEW_TABLE_USAGE` 的目录差异。
|
||||||
|
|
||||||
|
## 未完成与延期项
|
||||||
|
|
||||||
|
- `glob` high 依赖风险按用户批准的范围例外保留为独立治理欠项,未在 GOV-012 中升级前端测试依赖。
|
||||||
|
- GOV-012 尚未合并到 `main`、发布 `v0.0.10` 或封存 `v0.0.10` 基线;这些是后续 Task 8/9 的发布步骤。
|
||||||
|
- MCP GitNexus 传输故障仍待工具环境恢复,不影响已保存的 CLI/静态替代证据。
|
||||||
|
|
||||||
|
## 关键提交
|
||||||
|
|
||||||
|
- `06466f3`、`8e954a3`、`54fd928`、`7e9d906`、`dc8ad7d`
|
||||||
|
- `c11de09`、`3d12c83`、`874796a`、`afd186c`、`f1c412b`
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- `npm run test:schema-baseline`:52/52;`npm run test:governance`:10/10;`npm run test:mysql-baseline`:2/2。
|
||||||
|
- `npm run test:environment-baseline`:10/10;contracts:3/3;boundaries:16/16;deployment:6/6。
|
||||||
|
- `pnpm lint`、`pnpm typecheck`、Prettier、`npm run schema:verify` 均通过。
|
||||||
|
- 真实 development MySQL 5.7.42 连接成功,`a_share_analysis_dev` 五类对象均为 0,v0.0.9 manifest 已提交并推送。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# GitNexus 开发后基线
|
||||||
|
|
||||||
|
- 功能编号:GOV-012
|
||||||
|
- 记录时间:2026年08月18日 CST
|
||||||
|
- 分支:`codex/gov-012-node-schema-exporter`
|
||||||
|
- 当前提交:`f1c412b`
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Indexed commit: f1c412b
|
||||||
|
Nodes: 2,383
|
||||||
|
Edges: 3,234
|
||||||
|
Clusters: 54
|
||||||
|
Flows: 63
|
||||||
|
MCP: Transport closed(按 CLI 与静态证据降级)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实际影响
|
||||||
|
|
||||||
|
- 运行时只影响 Schema 治理工具、测试、治理文档和 `v0.0.9` 开发基线。
|
||||||
|
- HTTP/OpenAPI、Web API、消息契约、Redis、API、Scheduler、Worker 和部署入口没有代码变更。
|
||||||
|
- MySQL 连接仅命中用户确认的 development 配置和 `a_share_analysis_dev`;没有生产数据库访问。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# GitNexus 开发前基线
|
||||||
|
|
||||||
|
- 功能编号:GOV-012
|
||||||
|
- 记录时间:2026年08月18日 CST
|
||||||
|
- 分支:main
|
||||||
|
- 基线提交:`3555b7c2224e`
|
||||||
|
- 创建方式:用户授权跳过 `v0.0.9` Schema 备份后手工留痕
|
||||||
|
|
||||||
|
## 工作区
|
||||||
|
|
||||||
|
```text
|
||||||
|
(干净)
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Indexed commit: 3555b7c
|
||||||
|
Current commit: 3555b7c
|
||||||
|
Status: up-to-date
|
||||||
|
Nodes: 2,112
|
||||||
|
Edges: 2,790
|
||||||
|
Flows: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具限制
|
||||||
|
|
||||||
|
重新索引后 GitNexus MCP 连接返回 `Transport closed`。该限制不得解释为无影响;影响计划使用现有图谱历史、静态调用点和测试引用形成保守结论,并要求修改代码前重新尝试 MCP upstream impact。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GitNexus 前后对比
|
||||||
|
|
||||||
|
| 项目 | 开发前 | 开发后 | 判断 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Git 提交 | `3555b7c` | `f1c412b` | GOV-012 独立分支完成实现和文档提交 |
|
||||||
|
| 索引规模 | 2,112 nodes / 2,790 edges / 50 flows | 2,383 nodes / 3,234 edges / 63 flows | 仅新增导出器、测试和文档相关符号 |
|
||||||
|
| MCP | `Transport closed` | `Transport closed` | 使用 CLI、静态引用和回归测试降级核对 |
|
||||||
|
| 数据库范围 | 缺失 `v0.0.9` 基线 | `a_share_analysis_dev`,`captured_empty` | 开发门禁恢复 |
|
||||||
|
| 跨模块变更 | 待分析 | 无 HTTP/OpenAPI、消息、Redis、运行时服务变更 | 符合影响计划 |
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# GitNexus 变更检测
|
||||||
|
|
||||||
|
## 工具证据
|
||||||
|
|
||||||
|
- GitNexus MCP `detect_changes` 在开发前后均返回 `Transport closed`。
|
||||||
|
- `npx gitnexus analyze` 已在关键提交后执行,最终索引为 2,383 nodes、3,234 edges、63 flows。
|
||||||
|
- `npx gitnexus detect-changes --repo a-share-analysis` 返回 `No changes detected`;CLI 对当前未提交差异的识别不完整,不能单独作为无影响结论。
|
||||||
|
|
||||||
|
## 静态与 Git 交叉核对
|
||||||
|
|
||||||
|
- `git diff --stat` 和提交清单确认变更集中在 `tools/database/schema-baseline.mjs`、`tools/database/mysql2-schema-exporter.mjs`、对应测试、GOV-012 文档及 `database/schema-baselines/v0.0.9/manifest.yaml`。
|
||||||
|
- `rg` 静态引用确认 `runCommandLine -> runBaselineGeneration -> exportMysql57Schema` 及历史 `verifyBaselineDirectory` 调用链仍在预期治理路径内。
|
||||||
|
- 没有 HTTP/OpenAPI、Web API、RocketMQ、Redis、API、Scheduler、Worker 或生产部署文件变更。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
影响范围符合开发前计划;MCP 传输故障作为残余工具风险保留,不解释为无影响。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# GitNexus 开发前影响计划
|
||||||
|
|
||||||
|
- 功能编号:GOV-012
|
||||||
|
- 基线提交:`3555b7c2224e`
|
||||||
|
- 总体风险:HIGH(版本治理和全部后续开发入口)
|
||||||
|
|
||||||
|
## 直接与间接影响
|
||||||
|
|
||||||
|
### `runBaselineGeneration`
|
||||||
|
|
||||||
|
- 直接被 Schema CLI 入口和多组单元测试调用;从同步子进程改为异步数据库连接会影响生成命令、测试注入和错误传播。
|
||||||
|
- 间接影响 `schema:baseline` 产物以及后续 `schema:verify`、`assertCurrentReleasedBaselinePublished` 和 `governance:start` 的可用性。
|
||||||
|
- 控制:保留外部命令,使用可注入假连接先测试,真实开发库只在专项验证中读取。
|
||||||
|
|
||||||
|
### `assertMysql57Metadata` 与历史验证
|
||||||
|
|
||||||
|
- 同时服务 captured、captured_empty 和历史目录验证。直接修改现有字段语义会破坏 v1/v2 发布证据。
|
||||||
|
- 控制:保留 v1/v2 原验证分支,新增 v3 元数据验证函数或明确格式分支,不原地复用旧客户端字段。
|
||||||
|
|
||||||
|
### SQL 规范化、对象计数和安全扫描
|
||||||
|
|
||||||
|
- 新导出器输出不再完全等同 mysqldump 文本,可能影响 `normalizeSchemaSql`、`splitSqlStatements`、对象计数和敏感语句扫描。
|
||||||
|
- 控制:用六类对象固定夹具覆盖格式、分隔符、对象体 DML、`DEFINER`、自增值和确定性输出。
|
||||||
|
|
||||||
|
## 跨模块同步修改
|
||||||
|
|
||||||
|
- HTTP/OpenAPI、Web API、消息契约、Redis、API、Scheduler、Worker:无运行时影响。
|
||||||
|
- 数据库:只读开发库 Schema,不修改迁移、表或数据;生产数据库禁止访问。
|
||||||
|
- 依赖:根工作区新增 `mysql2` 直接依赖并更新锁文件,需审计安全、许可证和维护边界。
|
||||||
|
- 治理:Schema format 升级、开发流程、代码规范、GOV-007 现行说明和测试必须同步;历史档案保持原文。
|
||||||
|
- 测试:Schema 测试是主要影响面,治理测试和完整验证用于确认后续开发入口未回归。
|
||||||
|
- 文档:新增 GOV-012 PRD、设计、测试、回退和开发总结,记录 v0.0.9 基线补建结果。
|
||||||
|
|
||||||
|
## 高风险处置
|
||||||
|
|
||||||
|
这是全部版本开发的关键路径,风险至少为 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` 是依赖安全门禁。
|
||||||
|
|
||||||
|
### Task 1 依赖审计范围限定例外
|
||||||
|
|
||||||
|
- 官方 registry 审计确认既有 Web 测试依赖链 `@vue/test-utils -> js-beautify -> glob` 存在一项 high;新增的 `mysql2@3.23.3` 不在该依赖路径中。
|
||||||
|
- 用户于 2026-08-18 明确同意 GOV-012 使用范围限定例外继续:该既有风险作为单独治理欠项处理,本功能不得擅自升级前端测试依赖,也不得把该例外解释为 `mysql2` 安全审计通过之外的通用放行。
|
||||||
|
- GOV-012 发布前的代码 Review 和开发规范 Review 必须继续披露该残余风险;若后续审计发现 `mysql2`、Schema 导出链路或生产依赖新增 high/critical,则本例外不适用并立即阻断。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
id: GOV-012
|
||||||
|
name: 纯 Node.js 开发库 Schema 导出器
|
||||||
|
slug: node-schema-exporter
|
||||||
|
status: released
|
||||||
|
owner: project
|
||||||
|
created_at: 2026-08-18
|
||||||
|
requirements: requirements.md
|
||||||
|
prd: prd.md
|
||||||
|
design: design.md
|
||||||
|
implementation_plan: implementation-plan.md
|
||||||
|
traceability_note: 设计已确认;实施计划需在用户复核书面规格后创建
|
||||||
|
release: v0.0.10
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 纯 Node.js 开发库 Schema 导出器 PRD
|
||||||
|
|
||||||
|
- 功能编号:GOV-012
|
||||||
|
- PRD 版本:0.1
|
||||||
|
- 状态:approved
|
||||||
|
- 日期:2026-08-18
|
||||||
|
|
||||||
|
## 背景与问题
|
||||||
|
|
||||||
|
当前 `schema:baseline` 依赖本机 MySQL 5.7.x 的 `mysql` 和 `mysqldump` 可执行文件。开发机无法安装这些客户端时,命令在连接数据库前以 `spawnSync mysql ENOENT` 失败,导致上一发布版本无法形成 Schema 基线,后续 `governance:start` 和版本开发流程持续阻断。
|
||||||
|
|
||||||
|
项目已经采用 Node.js 22.x、pnpm 和 `mysql2`,不应要求开发机额外安装 MySQL Server,也不应通过 Docker、远程 shell 或非版本化人工导出绕过治理门禁。
|
||||||
|
|
||||||
|
## 产品目标
|
||||||
|
|
||||||
|
- 使用纯 Node.js 和 `mysql2` 从固定开发库导出完整、确定性、可验证的 Schema。
|
||||||
|
- 保持 `npm run schema:baseline`、`npm run schema:verify` 和 `governance:start` 的外部用法不变。
|
||||||
|
- 保留历史 format v1/v2 基线的只读兼容,不重写既有发布证据。
|
||||||
|
- 在实现完成后生成并推送 `v0.0.9` 基线,恢复自动版本开发流程。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 导出表、索引、外键、视图、触发器、存储过程、函数和 Event Scheduler 事件。
|
||||||
|
- 新增 format v3 清单,记录 Node 导出器、`mysql2` 驱动和 MySQL 5.7.x 服务端信息。
|
||||||
|
- 使用开发环境白名单、对象摘要前后校验、SQL 安全扫描、原子写入和 SHA-256 校验。
|
||||||
|
- 为导出、验证、历史兼容、错误恢复和真实空开发库补充测试。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不备份任何表数据。
|
||||||
|
- 不连接、探测或读取生产数据库。
|
||||||
|
- 不安装 MySQL Server、MySQL CLI、Docker 或远程导出服务。
|
||||||
|
- 不修改业务数据库结构、迁移、实体或应用查询。
|
||||||
|
- 不把 TypeORM 实体定义当作现有数据库真实结构的替代品。
|
||||||
|
|
||||||
|
## 用户流程
|
||||||
|
|
||||||
|
1. 开发者在开发下一版本前运行 `npm run schema:baseline`。
|
||||||
|
2. 工具固定读取 development 配置并验证目标库边界。
|
||||||
|
3. Node 导出器连接开发库、读取完整对象定义并生成版本目录。
|
||||||
|
4. 开发者运行 `npm run schema:verify`,提交并推送该目录。
|
||||||
|
5. `governance:start` 核对基线、远程分支和发布标签后允许下一功能开发。
|
||||||
|
|
||||||
|
## 成功指标
|
||||||
|
|
||||||
|
- 未安装 `mysql` 和 `mysqldump` 的开发机能够完成真实 Schema 基线生成。
|
||||||
|
- 相同结构重复导出产生逐字节一致的 SQL 和 SHA-256。
|
||||||
|
- format v1/v2 历史基线继续通过原有验证。
|
||||||
|
- 空开发库生成 `captured_empty`,非空开发库生成完整 `schema.sql`。
|
||||||
|
- 失败时不留下目标版本目录或半成品。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- Given 本机不存在 MySQL CLI,When 执行 `schema:baseline`,Then 工具通过 `mysql2` 连接固定开发库且不调用外部数据库命令。
|
||||||
|
- Given 数据库包含所有受支持对象,When 导出完成,Then SQL 包含完整对象定义且不包含数据、凭据和 `DEFINER`。
|
||||||
|
- Given 历史 format v1/v2 基线,When 执行 `schema:verify`,Then 继续按原规则通过或失败,文件不被重写。
|
||||||
|
- Given 导出期间对象摘要变化,When 执行完成前复核,Then 命令失败并清理临时目录。
|
||||||
|
- Given `v0.0.9` 基线已提交推送,When 执行 `governance:start`,Then 不再因本机缺少 MySQL CLI 被阻断。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- `SHOW CREATE` 权限不足会阻断完整导出,必须明确报错,不能跳过对象。
|
||||||
|
- 视图依赖和例程分隔符处理错误会降低恢复能力,必须用固定夹具和恢复顺序测试覆盖。
|
||||||
|
- 本功能位于版本治理关键路径,任何历史兼容回归都可能阻断全部后续开发。
|
||||||
|
|
||||||
|
## 发布与回退
|
||||||
|
|
||||||
|
GOV-012 作为独立治理版本发布。若新导出器不能生成确定性完整结构、历史基线验证回归或 `governance:start` 无法恢复,则不得发布;代码回退到 GOV-012 前提交,已发布的历史基线保持不变。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# QA 测试结果
|
||||||
|
|
||||||
|
## 通过项
|
||||||
|
|
||||||
|
- `npm run test:schema-baseline`:52/52 通过,包含 Node exporter、format v1/v2 只读兼容、format v3、错误脱敏、MySQL 5.7 视图目录回退和真实 `v0.0.9` 基线门禁。
|
||||||
|
- `npm run test:governance`:10/10 通过。
|
||||||
|
- `npm run test:mysql-baseline`:2/2 通过。
|
||||||
|
- `npm run test:environment-baseline`:10/10 通过。
|
||||||
|
- `npm run test:contracts`:3/3 通过。
|
||||||
|
- `npm run test:boundaries`:16/16 通过。
|
||||||
|
- `npm run test:deployment`:6/6 通过。
|
||||||
|
- `npm run check:runtime`:Node.js 22.11.0、pnpm 10.25.0 通过。
|
||||||
|
- `pnpm lint`:Web 和 Server ESLint 通过。
|
||||||
|
- `pnpm typecheck`:Web 与 Server TypeScript 检查通过。
|
||||||
|
- `npm run schema:verify`:`v0.0.9 (captured_empty)` 通过。
|
||||||
|
|
||||||
|
## 真实开发库
|
||||||
|
|
||||||
|
- 使用 Git 忽略的 development 本地配置,只读取 `a_share_analysis_dev` 的元数据和 `SHOW CREATE`;服务端为 MySQL 5.7.42,五类对象计数均为 0。
|
||||||
|
- 生成结果只有 `manifest.yaml`,没有 `schema.sql`、checksum 中没有密码或连接信息。
|
||||||
|
|
||||||
|
## 未执行项
|
||||||
|
|
||||||
|
- `npm run governance:start -- GOV-012 node-schema-exporter` 未重复覆盖已有开发前报告,工具按历史保护规则拒绝;已用 `schema:verify` 和独立远程基线检查替代验证。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 视觉 QA
|
||||||
|
|
||||||
|
## 适用性
|
||||||
|
|
||||||
|
GOV-012 只修改数据库治理工具、测试、文档和 Schema 清单,不修改 Web 页面、样式、图表或交互流程,因此不适用浏览器截图和视觉回归。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
下一项市场概览业务功能涉及 Web 页面时,必须按现行规范补充 Playwright 流程和同视口视觉证据;本结论不能外推到后续前端功能。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# GOV-012 发布说明(待发布)
|
||||||
|
|
||||||
|
## 计划版本
|
||||||
|
|
||||||
|
`v0.0.10`,发布主题为纯 Node.js 开发库 Schema 导出器。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
- 移除 Schema 生成对本机 MySQL CLI 的运行时依赖,改用 mysql2 和 MySQL 5.7.x `SHOW CREATE`。
|
||||||
|
- 新增 format v3,记录 exporter/driver 元数据;format v1/v2 保持只读兼容。
|
||||||
|
- 恢复下一版本开发前的 Schema 基线门禁,已封存 `v0.0.9` `captured_empty` 基线。
|
||||||
|
|
||||||
|
## 发布前置
|
||||||
|
|
||||||
|
- 先在 `main` 完成代码 Review、规范 Review、`governance:finish` 和全量验证。
|
||||||
|
- 创建并推送 `v0.0.10` 标签,再独立生成并推送 `database/schema-baselines/v0.0.10/manifest.yaml`。
|
||||||
|
- 发布失败时按 `rollback.md` 回退代码提交,禁止删除或覆盖 v0.0.9 基线。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 工程需求
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
- `schema:baseline` 不得调用本机 `mysql`、`mysqldump` 或其他外部数据库客户端。
|
||||||
|
- 根工作区直接声明并使用固定版本的 `mysql2`,不得从 Server 私有依赖路径加载。
|
||||||
|
- 工具只允许 development、`a_share_analysis_dev`、固定开发主机白名单、MySQL 5.7.x、`utf8mb4` 和 `utf8mb4_general_ci`。
|
||||||
|
- 使用 `SHOW CREATE` 导出基础表、视图、触发器、存储过程、函数和事件;索引与外键随表定义保存。
|
||||||
|
- 视图按库内依赖拓扑排序;循环依赖、未知依赖或对象丢失必须失败。
|
||||||
|
- format v3 清单记录导出器和驱动信息;format v1/v2 继续只读验证。
|
||||||
|
- 空库仍生成 `captured_empty`,MySQL 未启用仍生成 `not_applicable`,两者不得创建伪造 SQL。
|
||||||
|
- 版本目录继续使用临时目录、完整校验后原子重命名,既有目录只允许逐字节幂等复用。
|
||||||
|
|
||||||
|
## 安全需求
|
||||||
|
|
||||||
|
- 禁止查询业务表数据,禁止 DDL、DML、多语句、锁表和生产探测。
|
||||||
|
- 密码只能从被 Git 忽略的 development 配置读取,不进入异常、日志、清单、SQL 或命令参数。
|
||||||
|
- 顶层数据语句、连接串、疑似凭据、残留 `DEFINER` 和环境主机信息必须被拒绝。
|
||||||
|
- 对象名只能来自已验证的元数据,并使用 MySQL 标识符转义。
|
||||||
|
- 连接必须在所有成功和失败路径关闭。
|
||||||
|
|
||||||
|
## 性能与稳定性需求
|
||||||
|
|
||||||
|
- 使用单连接和有限并发,设置连接超时、查询超时、对象数量和响应大小保护。
|
||||||
|
- 中小型开发库目标在 30 秒内完成;超时不得保存部分基线。
|
||||||
|
- 导出前后读取对象摘要,两次不一致时失败,避免并发 DDL 形成混合快照。
|
||||||
|
- 相同输入必须生成确定性顺序、换行、清单和校验值。
|
||||||
|
|
||||||
|
## 兼容与测试需求
|
||||||
|
|
||||||
|
- 外部命令名称和参数保持不变。
|
||||||
|
- 现有 format v1/v2 夹具、状态和错误边界继续通过。
|
||||||
|
- 新增假连接单元测试,不以真实数据库作为普通测试门禁。
|
||||||
|
- 实施完成后只对固定开发库执行一次真实 `captured_empty` 或 `captured` 验证,禁止访问生产环境。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 代码 Review
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
Review 覆盖 `mysql2-schema-exporter.mjs`、`schema-baseline.mjs`、对应测试、format v3 清单和文档变更。Task 2/3 Review 发现的问题已通过修复提交关闭:触发器元数据严格校验、目录/SHOW CREATE/元数据错误脱敏、超时错误语义保留、例程字面量不误改、主机信息拒绝,以及 MySQL 5.7 视图目录回退。
|
||||||
|
|
||||||
|
## 安全与残余风险
|
||||||
|
|
||||||
|
- 只读 `information_schema` 和 `SHOW CREATE`,禁用多语句,不读取业务数据,不执行 DDL/DML,不锁表。
|
||||||
|
- 配置边界固定为 development、`a_share_analysis_dev` 和开发主机白名单;密码不进入日志、异常、SQL、清单或 checksum。
|
||||||
|
- 连接、查询、总时长、对象数和定义大小均有上限,所有路径最终关闭连接。
|
||||||
|
- 既有 Web 测试依赖链 `glob` 的 high 漏洞是用户批准的范围限定治理欠项,不在本功能新增路径;若 mysql2 或导出链出现 high/critical,必须阻断发布。
|
||||||
|
|
||||||
|
## 性能与资源影响
|
||||||
|
|
||||||
|
- 单连接、按目录顺序读取,查询 5 秒超时、总时长 30 秒、对象 5,000、单定义 1 MiB;视图排序为确定性 Kahn 算法。
|
||||||
|
- 治理命令不在在线请求路径,不新增 API、Redis、RocketMQ 或 Worker 负载;空库实际生成 `captured_empty`。
|
||||||
|
|
||||||
|
## GitNexus 与同步修改
|
||||||
|
|
||||||
|
- MCP `Transport closed`,已用 CLI reindex/status、静态引用和回归测试交叉核对;影响集中在 Schema 治理链路。
|
||||||
|
- HTTP/OpenAPI、Web API、消息、Redis、API、Scheduler、Worker、部署均无需同步修改;Schema 文档、测试脚本和 v0.0.9 基线已同步。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 开发规范符合性 Review
|
||||||
|
|
||||||
|
## 安全、性能与架构检查
|
||||||
|
|
||||||
|
- 代码使用中文注释说明开发库边界、错误脱敏、并发变化和 MySQL 5.7 回退;文件职责没有继续过度拆分。
|
||||||
|
- Node.js 22.x、mysql2、MySQL 5.7.x、`utf8mb4_general_ci` 和 format v3 约束已同步实现、规范和测试。
|
||||||
|
- 安全策略覆盖凭据、SQL 注入、生产边界、错误、依赖和残余风险;性能目标、保护阈值和不适用压测边界均有证据。
|
||||||
|
|
||||||
|
## GitNexus 与开发总结检查
|
||||||
|
|
||||||
|
- 开发前影响计划、开发后索引、变更检测和前后对比已归档;MCP 传输限制已明确记录。
|
||||||
|
- 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。
|
||||||
|
- 提交均为中文 Conventional Commit,当前分支和每次提交均已推送项目 Gitea;未修改 Git 全局配置。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 回退方案
|
||||||
|
|
||||||
|
## 代码回退
|
||||||
|
|
||||||
|
若纯 Node 导出器出现历史兼容、结构完整性或安全问题,回退 GOV-012 代码提交,恢复原 MySQL CLI 实现。不得删除或改写任何已发布的历史基线。
|
||||||
|
|
||||||
|
## format v3 处理
|
||||||
|
|
||||||
|
GOV-012 未发布前生成的 v3 测试产物可以删除并重新生成;一旦随正式版本发布,验证器必须永久保留 v3 只读兼容。回退不能把 v3 清单伪装为 v2。
|
||||||
|
|
||||||
|
## 数据库边界
|
||||||
|
|
||||||
|
本功能不修改数据库结构和数据,因此不执行数据库回滚。回退只涉及治理工具、依赖锁文件、文档和未发布的测试产物。
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
- 导出结果缺少任一受支持对象。
|
||||||
|
- 相同 Schema 无法稳定生成相同校验值。
|
||||||
|
- 历史 v1/v2 基线验证回归。
|
||||||
|
- 工具读取业务数据、泄露凭据或可能连接生产环境。
|
||||||
|
- `schema:verify` 或 `governance:start` 不能形成闭环。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# v0.0.9 Schema 基线启动例外
|
||||||
|
|
||||||
|
- 功能编号:GOV-012
|
||||||
|
- 日期:2026-08-18
|
||||||
|
- 适用范围:仅 GOV-012 开发启动
|
||||||
|
|
||||||
|
## 原因
|
||||||
|
|
||||||
|
本机不存在 MySQL 5.7.x `mysql` 与 `mysqldump` 客户端,现有工具在连接数据库前以 `spawnSync mysql ENOENT` 失败。GOV-012 的目标正是移除该本机依赖,因此无法先用旧工具生成 `v0.0.9` 基线再开发修复。
|
||||||
|
|
||||||
|
## 用户决定
|
||||||
|
|
||||||
|
用户明确授权本次跳过数据库备份。该授权只用于 GOV-012 启动,不建立通用跳过选项,也不继承到 GOV-012 之后的其他功能。
|
||||||
|
|
||||||
|
## 限制与补偿
|
||||||
|
|
||||||
|
- 不伪造空库、SQL、客户端版本或校验摘要。
|
||||||
|
- 不连接或探测生产数据库。
|
||||||
|
- 使用手工 GitNexus before/impact 记录替代自动 `governance:start` 的启动证据。
|
||||||
|
- 新导出器通过后必须优先生成、验证、单独提交并推送 `v0.0.9` 基线,再验证自动门禁恢复。
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 测试计划
|
||||||
|
|
||||||
|
## 单元与契约测试
|
||||||
|
|
||||||
|
- 旧实现下先证明无 MySQL CLI 时失败,再使纯 Node 路径转绿。
|
||||||
|
- 假连接覆盖表、视图依赖、触发器、过程、函数、事件和空库。
|
||||||
|
- 覆盖 MySQL 5.7.x 成功以及 5.6、8.x、MariaDB、字符集和排序规则失败。
|
||||||
|
- 覆盖视图拓扑排序、循环依赖、未知对象、权限不足、空 `SHOW CREATE` 和对象摘要漂移。
|
||||||
|
- 覆盖标识符转义、`DEFINER` 清除、当前自增值清除和稳定分隔符。
|
||||||
|
- 覆盖顶层数据操作、凭据、连接串和生产标识拒绝;对象体内合法 DML 保留。
|
||||||
|
- 覆盖连接成功、查询失败、渲染失败和持久化失败时的连接关闭与临时目录清理。
|
||||||
|
- 覆盖相同输入逐字节确定性和 SQL/manifest/checksum 篡改检测。
|
||||||
|
|
||||||
|
## 历史兼容
|
||||||
|
|
||||||
|
- 全部 format v1/v2 历史夹具继续验证。
|
||||||
|
- format v3 的 `captured`、`captured_empty` 和 `not_applicable` 分支通过。
|
||||||
|
- 旧字段不被新格式复用,历史目录不发生内容变化。
|
||||||
|
- `governance:start` 继续验证基线已跟踪、已推送、当前分支和远程标签一致。
|
||||||
|
|
||||||
|
## 真实开发库验证
|
||||||
|
|
||||||
|
- 只读取 `deploy/env/development/api.env.local` 和固定开发库。
|
||||||
|
- 记录服务端版本、字符规则、对象数量、耗时和最终状态,不记录密码、地址或 SQL 正文到聊天和 QA 摘要。
|
||||||
|
- 当前为空时生成 `captured_empty`;发现实际对象时生成完整 `captured`,不得按预期强制为空。
|
||||||
|
- 运行 `schema:verify`、治理测试、格式、lint、类型检查和完整仓库验证。
|
||||||
|
|
||||||
|
## 安全验证
|
||||||
|
|
||||||
|
- 证明没有子进程数据库客户端调用、业务表数据查询、生产配置读取或多语句执行。
|
||||||
|
- 固定夹具验证对象数量、响应大小、查询和总时长保护。
|
||||||
|
- 残余风险包括真实库权限差异和并发 DDL;通过失败关闭、摘要复核和人工重跑控制。
|
||||||
|
|
||||||
|
## 性能验证
|
||||||
|
|
||||||
|
- 假连接验证单连接、5 秒查询超时、30 秒总时长、5,000 对象和 1 MiB 单定义限制;真实开发库记录导出状态和耗时,不记录 SQL 或凭据。
|
||||||
|
- 本功能是低频治理命令,不在 API 请求路径,也不承载在线行情吞吐;因此不执行业务负载压测,边界是版本基线生成和验证命令本身。
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- 前端:Vue 3、Vite、TypeScript、Dart Sass、Naive UI;浏览器 HTTP 请求统一使用 Axios,接口类型由 OpenAPI 生成。
|
- 前端:Vue 3、Vite、TypeScript、Dart Sass、Naive UI;浏览器 HTTP 请求统一使用 Axios,接口类型由 OpenAPI 生成。
|
||||||
- 后端:NestJS、TypeScript。
|
- 后端:NestJS、TypeScript。
|
||||||
- 数据库:MySQL 5.7.42,字符集 `utf8mb4`,排序规则 `utf8mb4_general_ci`;缓存:Redis;消息队列:RocketMQ 5.x。
|
- 数据库:MySQL 5.7.x,字符集 `utf8mb4`,排序规则 `utf8mb4_general_ci`;缓存:Redis;消息队列:RocketMQ 5.x。
|
||||||
- 图表:Lightweight Charts负责K线和分时,ECharts负责热力图、分布和资金趋势。
|
- 图表:Lightweight Charts负责K线和分时,ECharts负责热力图、分布和资金趋势。
|
||||||
- AI:由服务端调用兼容 OpenAI 格式的付费 API,密钥不得下发浏览器。
|
- AI:由服务端调用兼容 OpenAI 格式的付费 API,密钥不得下发浏览器。
|
||||||
|
|
||||||
@@ -127,28 +127,30 @@ count += 1;
|
|||||||
- 业务代码只依赖项目定义的 `MessageBusAdapter` 和版本化事件契约,禁止直接依赖 RocketMQ SDK 类型;客户端、NameServer 或 Proxy 接入方式由基础设施适配层决定。
|
- 业务代码只依赖项目定义的 `MessageBusAdapter` 和版本化事件契约,禁止直接依赖 RocketMQ SDK 类型;客户端、NameServer 或 Proxy 接入方式由基础设施适配层决定。
|
||||||
- 免费数据源通过 Adapter 统一为内部模型,上层不得依赖供应商原始字段。
|
- 免费数据源通过 Adapter 统一为内部模型,上层不得依赖供应商原始字段。
|
||||||
|
|
||||||
## 11. MySQL 5.7 兼容规范
|
## 11. MySQL 5.7.x 兼容规范
|
||||||
|
|
||||||
- 开发、测试和生产环境统一使用 MySQL 5.7.42,禁止使用其他 5.7 补丁、`latest`、MySQL 8.x 或其他数据库代替兼容性验证。
|
- 开发、测试和生产环境统一使用 MySQL 5.7.x,不设置最低补丁版本;禁止使用 `latest`、MySQL 5.6.x、8.x、MariaDB 或其他数据库代替兼容性验证。
|
||||||
- 创建数据库必须显式声明 `DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci`;建表与迁移脚本必须显式保持 `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`。
|
- 创建数据库必须显式声明 `DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci`;建表与迁移脚本必须显式保持 `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`。
|
||||||
- ORM 和数据库驱动必须声明 `utf8mb4`,连接建立后验证会话、数据库和表的排序规则为 `utf8mb4_general_ci`,不得依赖服务器默认值。
|
- ORM 和数据库驱动必须声明 `utf8mb4`,连接建立后验证会话、数据库和表的排序规则为 `utf8mb4_general_ci`,不得依赖服务器默认值。
|
||||||
- 文本列默认继承表级规则。列级改变字符集或排序规则必须由具体功能设计解释,并通过 ADR 审批。
|
- 文本列默认继承表级规则。列级改变字符集或排序规则必须由具体功能设计解释,并通过 ADR 审批。
|
||||||
- 禁止使用 MySQL 8.x 专属或在 5.7 行为不同的公用表表达式、递归 CTE、窗口函数、函数索引、不可见索引、`utf8mb4_0900_*` 排序规则和 JSON 增强能力。
|
- 禁止使用 MySQL 8.x 专属或在 5.7 行为不同的公用表表达式、递归 CTE、窗口函数、函数索引、不可见索引、`utf8mb4_0900_*` 排序规则和 JSON 增强能力。
|
||||||
- 不得依赖 `CHECK` 约束在 MySQL 8.x 的强制执行语义;MySQL 5.7 下的业务约束必须由边界校验、普通索引或可测试的写入逻辑保证。
|
- 不得依赖 `CHECK` 约束在 MySQL 8.x 的强制执行语义;MySQL 5.7.x 下的业务约束必须由边界校验、普通索引或可测试的写入逻辑保证。
|
||||||
- `utf8mb4_general_ci` 不区分大小写。证券代码、外部事件 ID、幂等键等身份字段必须规范化输入,或显式采用经设计批准的二进制比较与唯一键,不能误把默认比较当成大小写敏感。
|
- `utf8mb4_general_ci` 不区分大小写。证券代码、外部事件 ID、幂等键等身份字段必须规范化输入,或显式采用经设计批准的二进制比较与唯一键,不能误把默认比较当成大小写敏感。
|
||||||
- 索引长度按 MySQL 5.7 和 `utf8mb4` 最多四字节字符预算;超长标识使用受控长度、前缀索引或稳定摘要时,必须说明唯一性和碰撞处理。
|
- 索引长度按 MySQL 5.7.x 和 `utf8mb4` 最多四字节字符预算;超长标识使用受控长度、前缀索引或稳定摘要时,必须说明唯一性和碰撞处理。
|
||||||
- JSON 字段只能使用 MySQL 5.7 已支持并经过真实环境验证的能力,查询和索引不得建立在 8.x 特性上。
|
- JSON 字段只能使用 MySQL 5.7.x 已支持并经过真实环境验证的能力,查询和索引不得建立在 8.x 特性上。
|
||||||
- 每份迁移脚本必须能够从上一版本基线在 MySQL 5.7 正向执行;禁止用直接修改共享数据库表代替迁移脚本。
|
- 每份迁移脚本必须能够从上一版本基线在 MySQL 5.7.x 正向执行;禁止用直接修改共享数据库表代替迁移脚本。
|
||||||
- 代码 Review 必须检查服务端版本、字符集、排序规则、SQL/迁移兼容性、索引长度和字符串比较语义;任一不符合项按 Important 处理并阻断发布。
|
- 代码 Review 必须检查服务端版本、字符集、排序规则、SQL/迁移兼容性、索引长度和字符串比较语义;任一不符合项按 Important 处理并阻断发布。
|
||||||
|
|
||||||
### Schema 基线规范
|
### Schema 基线规范
|
||||||
|
|
||||||
- 每个新应用版本开发前必须封存上一已发布版本的开发库 Schema;只能读取 `APP_ENV=development`、`a_share_analysis_dev` 和 MySQL 5.7,主机仅允许回环地址或固定开发白名单 `112.192.20.11`,禁止连接、探测或读取生产数据库。配置优先读取 Git 忽略的 `deploy/env/development/api.env.local`。
|
- 每个新应用版本开发前必须封存上一已发布版本的开发库 Schema;只能读取 `APP_ENV=development`、`a_share_analysis_dev` 和 MySQL 5.7.x,主机仅允许回环地址或固定开发白名单 `112.192.20.11`,禁止连接、探测或读取生产数据库。配置优先读取 Git 忽略的 `deploy/env/development/api.env.local`。
|
||||||
- 导出使用 MySQL 5.7 `mysqldump` 的 `--no-data --routines --triggers --events`,完整保留表、索引、外键、视图、触发器、存储过程、函数和事件,不得包含任何表数据。
|
- 导出使用 Node.js 22.x 与 `mysql2` 连接 MySQL 5.7.x,读取固定 `information_schema` 元数据并执行 `SHOW CREATE`,完整保留表、索引、外键、视图、触发器、存储过程、函数和事件,不得包含任何表数据;本机不要求安装 MySQL Server、`mysql` 或 `mysqldump`。
|
||||||
- 规范化必须确定性删除导出时间、环境 `DEFINER` 和表选项中的当前 `AUTO_INCREMENT=<值>`,同时保留列定义的 `AUTO_INCREMENT` 属性和业务 SQL。
|
- 规范化必须确定性删除导出时间、环境 `DEFINER` 和表选项中的当前 `AUTO_INCREMENT=<值>`,同时保留列定义的 `AUTO_INCREMENT` 属性和业务 SQL。
|
||||||
- 账号密码只能通过受控子进程环境传递,不得进入参数、日志、SQL、清单或 checksum 文件。
|
- 账号密码只存在于内存连接配置,不得进入命令参数、日志、异常、SQL、清单或 checksum 文件。
|
||||||
- `manifest.yaml`、`checksum.sha256` 和 `schema.sql` 的 SHA-256 必须一致;既有版本目录只允许内容完全一致的幂等验证,不允许覆盖。
|
- `manifest.yaml`、`checksum.sha256` 和 `schema.sql` 的 SHA-256 必须一致;既有版本目录只允许内容完全一致的幂等验证,不允许覆盖。
|
||||||
- MySQL 未启用时只创建状态为 `not_applicable` 的清单,不得创建空 `schema.sql`;启用但无对象时创建 format v2 的 `captured_empty` 清单和零对象计数,不伪造空 SQL;启用后的缺失基线必须阻断下一版本开发。既有 format v1 基线必须继续只读验证,不得静默升级或重写。
|
- 导出前后必须复核对象目录和定义摘要,发现并发 DDL 或定义变化立即失败;MySQL 5.7.x 不提供 `VIEW_TABLE_USAGE` 时,只能回退读取 `information_schema.VIEWS.VIEW_DEFINITION` 解析视图依赖。
|
||||||
|
- MySQL 未启用时只创建状态为 `not_applicable` 的清单,不得创建空 `schema.sql`;启用但无对象时创建 format v3 的 `captured_empty` 清单和零对象计数,不伪造空 SQL;启用后的缺失基线必须阻断下一版本开发。既有 format v1/v2 基线必须继续只读验证,不得静默升级或重写。
|
||||||
|
- format v3 清单记录 Node exporter 和数据库驱动版本,不复用 v1/v2 的 `mysql_client_version` 与 `mysqldump_client_version` 字段。
|
||||||
|
|
||||||
## 12. 测试要求
|
## 12. 测试要求
|
||||||
|
|
||||||
@@ -176,7 +178,7 @@ count += 1;
|
|||||||
- 后端评估数据量与算法复杂度、MySQL 查询/索引/N+1、Redis 用途/TTL/一致性/击穿、API 载荷与分页、Scheduler/Worker 并发/幂等/背压,以及外部数据源和 AI 的超时/重试/批处理/成本。
|
- 后端评估数据量与算法复杂度、MySQL 查询/索引/N+1、Redis 用途/TTL/一致性/击穿、API 载荷与分页、Scheduler/Worker 并发/幂等/背压,以及外部数据源和 AI 的超时/重试/批处理/成本。
|
||||||
- 前端评估依赖与构建体积、渲染次数、长列表、图表数据量、交互响应和内存占用;不能只以开发环境主观感觉作为证据。
|
- 前端评估依赖与构建体积、渲染次数、长列表、图表数据量、交互响应和内存占用;不能只以开发环境主观感觉作为证据。
|
||||||
- 高风险路径必须提供 benchmark、profile 或负载测试;低频治理逻辑等不适用场景必须写明无需负载测试的原因和边界。
|
- 高风险路径必须提供 benchmark、profile 或负载测试;低频治理逻辑等不适用场景必须写明无需负载测试的原因和边界。
|
||||||
- 性能优化不得牺牲数据口径、安全、可观察性、异常恢复或 MySQL 5.7 兼容性;禁止无证据的预优化。
|
- 性能优化不得牺牲数据口径、安全、可观察性、异常恢复或 MySQL 5.7.x 兼容性;禁止无证据的预优化。
|
||||||
|
|
||||||
## 15. 代码 Review 要求
|
## 15. 代码 Review 要求
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ released → superseded
|
|||||||
## 3. 开发前流程
|
## 3. 开发前流程
|
||||||
|
|
||||||
1. 确认上一已发布语义化版本及标签提交,执行 `npm run schema:baseline` 和 `npm run schema:verify`。
|
1. 确认上一已发布语义化版本及标签提交,执行 `npm run schema:baseline` 和 `npm run schema:verify`。
|
||||||
2. MySQL 已启用时只能从 `APP_ENV=development`、`a_share_analysis_dev`、MySQL 5.7、`utf8mb4_general_ci` 和固定开发主机白名单(回环地址或 `112.192.20.11`)导出 Schema;配置优先读取被 Git 忽略的 `deploy/env/development/api.env.local`,不存在时才读取无秘密模板。空开发库保存 format v2 的 `captured_empty` 清单,不创建空 SQL;MySQL 未启用时只保存 `not_applicable` 清单。
|
2. MySQL 已启用时只能从 `APP_ENV=development`、`a_share_analysis_dev`、MySQL 5.7.x、`utf8mb4_general_ci` 和固定开发主机白名单(回环地址或 `112.192.20.11`)导出 Schema;配置优先读取被 Git 忽略的 `deploy/env/development/api.env.local`,不存在时才读取无秘密模板。空开发库保存 format v3 的 `captured_empty` 清单,不创建空 SQL;MySQL 未启用时只保存 `not_applicable` 清单。
|
||||||
3. 将 `database/schema-baselines/<上一发布版本>/` 作为独立中文提交推送 Gitea,核对本地和远程 SHA;既有版本目录禁止覆盖。只有 Git 已跟踪、工作区无改动、当前分支已推送且远程发布标签提交一致时,`governance:start` 才会继续。
|
3. 将 `database/schema-baselines/<上一发布版本>/` 作为独立中文提交推送 Gitea,核对本地和远程 SHA;既有版本目录禁止覆盖。只有 Git 已跟踪、工作区无改动、当前分支已推送且远程发布标签提交一致时,`governance:start` 才会继续。
|
||||||
4. 分配功能编号和英文短名称。
|
4. 分配功能编号和英文短名称。
|
||||||
5. 建立 `docs/features/<编号>-<短名称>/`。
|
5. 建立 `docs/features/<编号>-<短名称>/`。
|
||||||
@@ -101,10 +101,10 @@ npm run governance:finish -- FEAT-MO-001 market-overview
|
|||||||
|
|
||||||
- 基线目录固定为 `database/schema-baselines/vX.Y.Z/`,版本是被备份的已发布版本。
|
- 基线目录固定为 `database/schema-baselines/vX.Y.Z/`,版本是被备份的已发布版本。
|
||||||
- 真实基线由 `schema.sql`、`manifest.yaml`、`checksum.sha256` 组成;范围包括表、索引、外键、视图、触发器、存储过程、函数和 Event Scheduler 事件,不含数据。
|
- 真实基线由 `schema.sql`、`manifest.yaml`、`checksum.sha256` 组成;范围包括表、索引、外键、视图、触发器、存储过程、函数和 Event Scheduler 事件,不含数据。
|
||||||
- MySQL 未启用时目录只能包含 `manifest.yaml`,状态为 `not_applicable`、原因为 `mysql_disabled`;启用但无对象时目录也只包含 `manifest.yaml`,状态为 format v2 的 `captured_empty` 并记录零对象计数。
|
- MySQL 未启用时目录只能包含 `manifest.yaml`,状态为 `not_applicable`、原因为 `mysql_disabled`;启用但无对象时目录也只包含 `manifest.yaml`,状态为 format v3 的 `captured_empty` 并记录零对象计数。
|
||||||
- `schema:baseline` 不接受 env 路径、环境类型或数据库名参数;配置路径固定在开发环境,生产配置和生产数据库不属于该命令的权限范围。
|
- `schema:baseline` 不接受 env 路径、环境类型或数据库名参数;配置路径固定在开发环境,生产配置和生产数据库不属于该命令的权限范围。
|
||||||
- 修正已发布基线必须新增治理记录说明原因,禁止静默覆盖或重写历史目录。
|
- 修正已发布基线必须新增治理记录说明原因,禁止静默覆盖或重写历史目录。
|
||||||
- 首次启用 MySQL 的版本必须在 MySQL 5.7 开发环境生成第一份真实或 `captured_empty` Schema 基线;format v1 历史清单保持只读兼容,禁止因工具升级重写。
|
- 首次启用 MySQL 的版本必须在 MySQL 5.7 开发环境生成第一份真实或 `captured_empty` Schema 基线;format v1/v2 历史清单保持只读兼容,禁止因工具升级重写。
|
||||||
|
|
||||||
## 8. 提交流程
|
## 8. 提交流程
|
||||||
|
|
||||||
|
|||||||
@@ -47,5 +47,7 @@
|
|||||||
- [GOV-008:RocketMQ 消息队列架构调整](features/GOV-008-rocketmq-adoption/manifest.yaml)
|
- [GOV-008:RocketMQ 消息队列架构调整](features/GOV-008-rocketmq-adoption/manifest.yaml)
|
||||||
- [GOV-009:应用工程脚手架基线](features/GOV-009-application-scaffold-baseline/manifest.yaml)
|
- [GOV-009:应用工程脚手架基线](features/GOV-009-application-scaffold-baseline/manifest.yaml)
|
||||||
- [GOV-010:开发质量与前后端架构规范](features/GOV-010-development-quality-architecture/manifest.yaml)
|
- [GOV-010:开发质量与前后端架构规范](features/GOV-010-development-quality-architecture/manifest.yaml)
|
||||||
|
- [GOV-011:Node 与 MySQL 系列版本约束(v0.0.9)](features/GOV-011-runtime-version-series/manifest.yaml)
|
||||||
|
- [GOV-012:纯 Node.js 开发库 Schema 导出器](features/GOV-012-node-schema-exporter/manifest.yaml)
|
||||||
|
|
||||||
功能档案是需求、设计、代码、测试、版本和回退之间的唯一正式关联入口。
|
功能档案是需求、设计、代码、测试、版本和回退之间的唯一正式关联入口。
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,7 @@
|
|||||||
"test:governance": "node --test tools/governance/feature-record.test.mjs",
|
"test:governance": "node --test tools/governance/feature-record.test.mjs",
|
||||||
"test:mysql-baseline": "node --test tools/governance/mysql-baseline.test.mjs",
|
"test:mysql-baseline": "node --test tools/governance/mysql-baseline.test.mjs",
|
||||||
"test:environment-baseline": "node --test tools/governance/environment-baseline.test.mjs",
|
"test:environment-baseline": "node --test tools/governance/environment-baseline.test.mjs",
|
||||||
"test:schema-baseline": "node --test tools/database/schema-baseline.test.mjs",
|
"test:schema-baseline": "node --test tools/database/schema-baseline.test.mjs tools/database/mysql2-schema-exporter.test.mjs",
|
||||||
"schema:baseline": "node tools/database/schema-baseline.mjs generate",
|
"schema:baseline": "node tools/database/schema-baseline.mjs generate",
|
||||||
"schema:verify": "node tools/database/schema-baseline.mjs verify",
|
"schema:verify": "node tools/database/schema-baseline.mjs verify",
|
||||||
"governance:start": "node tools/governance/start.mjs",
|
"governance:start": "node tools/governance/start.mjs",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"eslint": "9.39.5",
|
"eslint": "9.39.5",
|
||||||
"eslint-plugin-vue": "9.33.0",
|
"eslint-plugin-vue": "9.33.0",
|
||||||
"globals": "17.11.0",
|
"globals": "17.11.0",
|
||||||
|
"mysql2": "3.23.3",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.46.2"
|
"typescript-eslint": "8.46.2"
|
||||||
|
|||||||
Generated
+3
@@ -26,6 +26,9 @@ importers:
|
|||||||
globals:
|
globals:
|
||||||
specifier: 17.11.0
|
specifier: 17.11.0
|
||||||
version: 17.11.0
|
version: 17.11.0
|
||||||
|
mysql2:
|
||||||
|
specifier: 3.23.3
|
||||||
|
version: 3.23.3(@types/node@22.20.0)
|
||||||
prettier:
|
prettier:
|
||||||
specifier: 3.9.6
|
specifier: 3.9.6
|
||||||
version: 3.9.6
|
version: 3.9.6
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface RedisRuntimeConfig {
|
|||||||
tls: boolean;
|
tls: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MySQL 配置固定 5.7.42 兼容口径,启用状态决定凭据是否必填。 */
|
/** MySQL 配置固定 5.7.x 兼容口径,启用状态决定凭据是否必填。 */
|
||||||
export interface MysqlRuntimeConfig {
|
export interface MysqlRuntimeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('基础设施只读健康客户端', () => {
|
|||||||
expect(commands).toEqual(['CONNECT', 'PING']);
|
expect(commands).toEqual(['CONNECT', 'PING']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MySQL 使用固定元数据查询并校验 5.7 与数据库口径', async () => {
|
it('MySQL 使用固定元数据查询并返回实际 5.7.x 版本', async () => {
|
||||||
const queries: string[] = [];
|
const queries: string[] = [];
|
||||||
const client = new MysqlHealthClient(
|
const client = new MysqlHealthClient(
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ describe('基础设施只读健康客户端', () => {
|
|||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
version: '5.7.42-log',
|
version: '5.7.41-log',
|
||||||
databaseName: 'a_share_analysis_dev',
|
databaseName: 'a_share_analysis_dev',
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
collation: 'utf8mb4_general_ci',
|
collation: 'utf8mb4_general_ci',
|
||||||
@@ -49,7 +49,7 @@ describe('基础设施只读健康客户端', () => {
|
|||||||
|
|
||||||
await expect(client.check()).resolves.toEqual({
|
await expect(client.check()).resolves.toEqual({
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
detail: 'MySQL 5.7.42 / a_share_analysis_dev / +08:00',
|
detail: 'MySQL 5.7.41 / a_share_analysis_dev / +08:00',
|
||||||
});
|
});
|
||||||
expect(queries).toEqual([MYSQL_HEALTH_QUERY]);
|
expect(queries).toEqual([MYSQL_HEALTH_QUERY]);
|
||||||
});
|
});
|
||||||
@@ -76,14 +76,17 @@ describe('基础设施只读健康客户端', () => {
|
|||||||
'a_share_analysis_dev',
|
'a_share_analysis_dev',
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(createClient({ version: '8.0.40' }).check()).rejects.toThrow(/MySQL 5.7/);
|
for (const version of ['5.6.51', '8.0.40', '5.7', '5.7.x']) {
|
||||||
await expect(createClient({ version: '5.7.41' }).check()).rejects.toThrow(/5\.7\.42/);
|
await expect(createClient({ version }).check()).rejects.toThrow(/MySQL 5\.7\.x/);
|
||||||
|
}
|
||||||
await expect(createClient({ databaseName: 'a_share_analysis' }).check()).rejects.toThrow(
|
await expect(createClient({ databaseName: 'a_share_analysis' }).check()).rejects.toThrow(
|
||||||
/a_share_analysis_dev/,
|
/a_share_analysis_dev/,
|
||||||
);
|
);
|
||||||
|
await expect(createClient({ charset: 'utf8' }).check()).rejects.toThrow(/utf8mb4/);
|
||||||
await expect(createClient({ collation: 'utf8mb4_0900_ai_ci' }).check()).rejects.toThrow(
|
await expect(createClient({ collation: 'utf8mb4_0900_ai_ci' }).check()).rejects.toThrow(
|
||||||
/utf8mb4_general_ci/,
|
/utf8mb4_general_ci/,
|
||||||
);
|
);
|
||||||
|
await expect(createClient({ timezone: 'SYSTEM' }).check()).rejects.toThrow(/\+08:00/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('MySQL 按当前配置校验生产库名', async () => {
|
it('MySQL 按当前配置校验生产库名', async () => {
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export class MysqlHealthClient implements OnModuleDestroy {
|
|||||||
const [rows] = await this.client.query(MYSQL_HEALTH_QUERY);
|
const [rows] = await this.client.query(MYSQL_HEALTH_QUERY);
|
||||||
const row = (rows as unknown as MysqlMetadataRow[])[0];
|
const row = (rows as unknown as MysqlMetadataRow[])[0];
|
||||||
if (!row) throw new Error('MySQL 未返回元数据');
|
if (!row) throw new Error('MySQL 未返回元数据');
|
||||||
if (!/^5\.7\.42(?:[-+.]|$)/.test(row.version)) {
|
const versionMatch = /^(5\.7\.\d+)(?:[-+].*)?$/.exec(row.version ?? '');
|
||||||
throw new Error('MySQL 5.7.42 版本要求未满足');
|
if (!versionMatch) {
|
||||||
|
throw new Error('MySQL 5.7.x 版本要求未满足');
|
||||||
}
|
}
|
||||||
if (row.databaseName !== this.expectedDatabase) {
|
if (row.databaseName !== this.expectedDatabase) {
|
||||||
throw new Error(`MySQL 健康检查必须连接 ${this.expectedDatabase}`);
|
throw new Error(`MySQL 健康检查必须连接 ${this.expectedDatabase}`);
|
||||||
@@ -50,7 +51,7 @@ export class MysqlHealthClient implements OnModuleDestroy {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
detail: `MySQL 5.7.42 / ${row.databaseName} / ${row.timezone}`,
|
detail: `MySQL ${versionMatch[1]} / ${row.databaseName} / ${row.timezone}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export class MysqlHealthClient implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建最多两个连接的只读健康池,并显式应用 MySQL 5.7.42 会话时区。 */
|
/** 创建最多两个连接的只读健康池,并显式应用 MySQL 5.7.x 会话时区。 */
|
||||||
export function createMysqlHealthClient(config: MysqlRuntimeConfig): MysqlHealthClient {
|
export function createMysqlHealthClient(config: MysqlRuntimeConfig): MysqlHealthClient {
|
||||||
const pool: Pool = createPool({
|
const pool: Pool = createPool({
|
||||||
host: config.host,
|
host: config.host,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectio
|
|||||||
import type { MysqlRuntimeConfig } from '../../config/src/runtime-config';
|
import type { MysqlRuntimeConfig } from '../../config/src/runtime-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 MySQL 5.7.42 TypeORM 基线选项;迁移未落地前不自动运行,且永久禁止 synchronize。
|
* 生成 MySQL 5.7.x TypeORM 基线选项;迁移未落地前不自动运行,且永久禁止 synchronize。
|
||||||
*/
|
*/
|
||||||
export function buildTypeOrmOptions(config: MysqlRuntimeConfig): MysqlConnectionOptions {
|
export function buildTypeOrmOptions(config: MysqlRuntimeConfig): MysqlConnectionOptions {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const mysql2Version = require('mysql2/package.json').version;
|
||||||
|
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
||||||
|
const VALID_TRIGGER_TIMINGS = new Set(['BEFORE', 'AFTER']);
|
||||||
|
const VALID_TRIGGER_EVENTS = new Set(['INSERT', 'UPDATE', 'DELETE']);
|
||||||
|
|
||||||
|
const CATALOG_QUERIES = Object.freeze({
|
||||||
|
tables: `SELECT TABLE_NAME, TABLE_TYPE
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = ?
|
||||||
|
ORDER BY TABLE_NAME`,
|
||||||
|
viewDependencies: `SELECT VIEW_NAME, TABLE_SCHEMA AS REFERENCED_SCHEMA_NAME,
|
||||||
|
TABLE_NAME AS REFERENCED_TABLE_NAME
|
||||||
|
FROM information_schema.VIEW_TABLE_USAGE
|
||||||
|
WHERE VIEW_SCHEMA = ?
|
||||||
|
ORDER BY VIEW_NAME, REFERENCED_SCHEMA_NAME, REFERENCED_TABLE_NAME`,
|
||||||
|
viewDefinitions: `SELECT TABLE_NAME AS VIEW_NAME, VIEW_DEFINITION
|
||||||
|
FROM information_schema.VIEWS
|
||||||
|
WHERE TABLE_SCHEMA = ?
|
||||||
|
ORDER BY TABLE_NAME`,
|
||||||
|
routines: `SELECT ROUTINE_NAME, ROUTINE_TYPE
|
||||||
|
FROM information_schema.ROUTINES
|
||||||
|
WHERE ROUTINE_SCHEMA = ?
|
||||||
|
ORDER BY ROUTINE_TYPE, ROUTINE_NAME`,
|
||||||
|
triggers: `SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, ACTION_TIMING,
|
||||||
|
EVENT_MANIPULATION, ACTION_ORDER
|
||||||
|
FROM information_schema.TRIGGERS
|
||||||
|
WHERE TRIGGER_SCHEMA = ?
|
||||||
|
ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION,
|
||||||
|
ACTION_ORDER, TRIGGER_NAME`,
|
||||||
|
events: `SELECT EVENT_NAME
|
||||||
|
FROM information_schema.EVENTS
|
||||||
|
WHERE EVENT_SCHEMA = ?
|
||||||
|
ORDER BY EVENT_NAME`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const SERVER_METADATA_QUERY = `SELECT VERSION() AS SERVER_VERSION,
|
||||||
|
DEFAULT_CHARACTER_SET_NAME AS DATABASE_CHARSET,
|
||||||
|
DEFAULT_COLLATION_NAME AS DATABASE_COLLATION
|
||||||
|
FROM information_schema.SCHEMATA
|
||||||
|
WHERE SCHEMA_NAME = ?`;
|
||||||
|
|
||||||
|
const CREATE_RESULT_COLUMNS = Object.freeze({
|
||||||
|
TABLE: 'Create Table',
|
||||||
|
VIEW: 'Create View',
|
||||||
|
PROCEDURE: 'Create Procedure',
|
||||||
|
FUNCTION: 'Create Function',
|
||||||
|
TRIGGER: 'SQL Original Statement',
|
||||||
|
EVENT: 'Create Event',
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const serverVersion = metadata?.serverVersion ?? '';
|
||||||
|
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(serverVersion) || /mariadb/i.test(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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCatalogText(value) {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录字段无效');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryCatalogRows(connection, sql, database, limits, options = {}) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await connection.query({
|
||||||
|
sql,
|
||||||
|
values: [database],
|
||||||
|
timeout: limits.queryTimeoutMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (options.allowMissingTable && error?.code === 'ER_UNKNOWN_TABLE') {
|
||||||
|
throw new SchemaExportError('OPTIONAL_CATALOG_UNAVAILABLE', '可选 Schema 目录来源不可用');
|
||||||
|
}
|
||||||
|
// mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。
|
||||||
|
sanitizeDatabaseQueryError(error);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效');
|
||||||
|
}
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquoteViewIdentifier(identifier) {
|
||||||
|
const value = identifier.startsWith('`') && identifier.endsWith('`')
|
||||||
|
? identifier.slice(1, -1).replaceAll('``', '`')
|
||||||
|
: identifier;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseViewDependencies(viewRows, database) {
|
||||||
|
const dependencies = [];
|
||||||
|
for (const row of viewRows) {
|
||||||
|
const view = requireCatalogText(row.VIEW_NAME);
|
||||||
|
const definition = requireCatalogText(row.VIEW_DEFINITION);
|
||||||
|
const inspectable = maskStringLiterals(definition);
|
||||||
|
const referencePattern = /\b(?:FROM|JOIN)\s+((?:`[^`]+`|[A-Za-z0-9_$]+)(?:\s*\.\s*(?:`[^`]+`|[A-Za-z0-9_$]+))?)/gi;
|
||||||
|
for (const match of inspectable.matchAll(referencePattern)) {
|
||||||
|
const parts = match[1].split(/\s*\.\s*/).map(unquoteViewIdentifier);
|
||||||
|
dependencies.push({
|
||||||
|
view,
|
||||||
|
referencedSchema: parts.length === 2 ? parts[0] : database,
|
||||||
|
referencedObject: parts.length === 2 ? parts[1] : parts[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.view.localeCompare(right.view) ||
|
||||||
|
left.referencedSchema.localeCompare(right.referencedSchema) ||
|
||||||
|
left.referencedObject.localeCompare(right.referencedObject),
|
||||||
|
);
|
||||||
|
return dependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObjectLimit(count, limits) {
|
||||||
|
if (!Number.isInteger(limits.maxObjects) || limits.maxObjects < 0 || count > limits.maxObjects) {
|
||||||
|
throw new SchemaExportError('OBJECT_LIMIT_EXCEEDED', 'Schema 对象数量超过安全上限');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录查询只从 information_schema 读取固定列,库名始终作为参数传入。每类对象
|
||||||
|
* 在进入后续 SHOW CREATE 前先转换成稳定的小对象,并在累计数量越界时立即停止。
|
||||||
|
*/
|
||||||
|
export async function readSchemaCatalog(connection, database, limits = DEFAULT_EXPORT_LIMITS) {
|
||||||
|
const tableRows = await queryCatalogRows(connection, CATALOG_QUERIES.tables, database, limits);
|
||||||
|
const tables = [];
|
||||||
|
const views = [];
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const name = requireCatalogText(row.TABLE_NAME);
|
||||||
|
if (row.TABLE_TYPE === 'BASE TABLE') tables.push(name);
|
||||||
|
else if (row.TABLE_TYPE === 'VIEW') views.push(name);
|
||||||
|
else throw new SchemaExportError('UNKNOWN_TABLE_TYPE', 'Schema 目录包含未知表类型');
|
||||||
|
}
|
||||||
|
tables.sort();
|
||||||
|
views.sort();
|
||||||
|
let objectCount = tables.length + views.length;
|
||||||
|
assertObjectLimit(objectCount, limits);
|
||||||
|
|
||||||
|
let viewDependencies;
|
||||||
|
try {
|
||||||
|
const dependencyRows = await queryCatalogRows(
|
||||||
|
connection,
|
||||||
|
CATALOG_QUERIES.viewDependencies,
|
||||||
|
database,
|
||||||
|
limits,
|
||||||
|
{ allowMissingTable: true },
|
||||||
|
);
|
||||||
|
viewDependencies = dependencyRows.map((row) => ({
|
||||||
|
view: requireCatalogText(row.VIEW_NAME),
|
||||||
|
referencedSchema: requireCatalogText(row.REFERENCED_SCHEMA_NAME),
|
||||||
|
referencedObject: requireCatalogText(row.REFERENCED_TABLE_NAME),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'OPTIONAL_CATALOG_UNAVAILABLE') throw error;
|
||||||
|
const viewRows = await queryCatalogRows(
|
||||||
|
connection,
|
||||||
|
CATALOG_QUERIES.viewDefinitions,
|
||||||
|
database,
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
viewDependencies = parseViewDependencies(viewRows, database);
|
||||||
|
}
|
||||||
|
|
||||||
|
const routineRows = await queryCatalogRows(connection, CATALOG_QUERIES.routines, database, limits);
|
||||||
|
const routines = routineRows.map((row) => {
|
||||||
|
const type = requireCatalogText(row.ROUTINE_TYPE);
|
||||||
|
if (!['FUNCTION', 'PROCEDURE'].includes(type)) {
|
||||||
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 目录包含未知例程类型');
|
||||||
|
}
|
||||||
|
return { name: requireCatalogText(row.ROUTINE_NAME), type };
|
||||||
|
});
|
||||||
|
routines.sort((left, right) => left.type.localeCompare(right.type) || left.name.localeCompare(right.name));
|
||||||
|
objectCount += routines.length;
|
||||||
|
assertObjectLimit(objectCount, limits);
|
||||||
|
|
||||||
|
const triggerRows = await queryCatalogRows(connection, CATALOG_QUERIES.triggers, database, limits);
|
||||||
|
const triggers = triggerRows.map((row) => {
|
||||||
|
const timing = requireCatalogText(row.ACTION_TIMING);
|
||||||
|
const event = requireCatalogText(row.EVENT_MANIPULATION);
|
||||||
|
const rawOrder = row.ACTION_ORDER;
|
||||||
|
const order = Number(rawOrder);
|
||||||
|
if (
|
||||||
|
!VALID_TRIGGER_TIMINGS.has(timing) ||
|
||||||
|
!VALID_TRIGGER_EVENTS.has(event) ||
|
||||||
|
(typeof rawOrder !== 'number' && typeof rawOrder !== 'string') ||
|
||||||
|
(typeof rawOrder === 'string' && rawOrder.trim().length === 0) ||
|
||||||
|
!Number.isInteger(order) ||
|
||||||
|
order < 1
|
||||||
|
) {
|
||||||
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 触发器执行顺序无效');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: requireCatalogText(row.TRIGGER_NAME),
|
||||||
|
table: requireCatalogText(row.EVENT_OBJECT_TABLE),
|
||||||
|
timing,
|
||||||
|
event,
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
triggers.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.table.localeCompare(right.table) ||
|
||||||
|
left.timing.localeCompare(right.timing) ||
|
||||||
|
left.event.localeCompare(right.event) ||
|
||||||
|
left.order - right.order ||
|
||||||
|
left.name.localeCompare(right.name),
|
||||||
|
);
|
||||||
|
objectCount += triggers.length;
|
||||||
|
assertObjectLimit(objectCount, limits);
|
||||||
|
|
||||||
|
const eventRows = await queryCatalogRows(connection, CATALOG_QUERIES.events, database, limits);
|
||||||
|
const events = eventRows.map((row) => requireCatalogText(row.EVENT_NAME)).sort();
|
||||||
|
objectCount += events.length;
|
||||||
|
assertObjectLimit(objectCount, limits);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
tables: Object.freeze(tables),
|
||||||
|
views: Object.freeze(views),
|
||||||
|
viewDependencies: Object.freeze(viewDependencies.map((dependency) => Object.freeze(dependency))),
|
||||||
|
routines: Object.freeze(routines.map((routine) => Object.freeze(routine))),
|
||||||
|
triggers: Object.freeze(triggers.map((trigger) => Object.freeze(trigger))),
|
||||||
|
events: Object.freeze(events),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视图恢复顺序使用稳定 Kahn 拓扑排序。跨库或未知依赖不能被默默忽略,否则恢复
|
||||||
|
* 出来的 Schema 可能引用未捕获对象;同层节点始终按名称排序以保证逐字节确定性。
|
||||||
|
*/
|
||||||
|
export function orderViewNames(catalog) {
|
||||||
|
const tables = new Set(catalog.tables ?? []);
|
||||||
|
const views = new Set(catalog.views ?? []);
|
||||||
|
const incoming = new Map([...views].map((view) => [view, 0]));
|
||||||
|
const dependents = new Map([...views].map((view) => [view, []]));
|
||||||
|
|
||||||
|
for (const dependency of catalog.viewDependencies ?? []) {
|
||||||
|
if (dependency.referencedSchema !== DEVELOPMENT_DATABASE) {
|
||||||
|
throw new SchemaExportError(
|
||||||
|
'CROSS_SCHEMA_VIEW_DEPENDENCY',
|
||||||
|
'Schema 视图包含跨库依赖',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!views.has(dependency.view)) {
|
||||||
|
throw new SchemaExportError('UNKNOWN_VIEW_DEPENDENCY', 'Schema 视图依赖来源未知');
|
||||||
|
}
|
||||||
|
if (tables.has(dependency.referencedObject)) continue;
|
||||||
|
if (!views.has(dependency.referencedObject)) {
|
||||||
|
throw new SchemaExportError('UNKNOWN_VIEW_DEPENDENCY', 'Schema 视图依赖对象未知');
|
||||||
|
}
|
||||||
|
incoming.set(dependency.view, incoming.get(dependency.view) + 1);
|
||||||
|
dependents.get(dependency.referencedObject).push(dependency.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const names of dependents.values()) names.sort();
|
||||||
|
const ready = [...views].filter((view) => incoming.get(view) === 0).sort();
|
||||||
|
const ordered = [];
|
||||||
|
while (ready.length > 0) {
|
||||||
|
const view = ready.shift();
|
||||||
|
ordered.push(view);
|
||||||
|
for (const dependent of dependents.get(view)) {
|
||||||
|
incoming.set(dependent, incoming.get(dependent) - 1);
|
||||||
|
if (incoming.get(dependent) === 0) {
|
||||||
|
ready.push(dependent);
|
||||||
|
ready.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ordered.length !== views.size) {
|
||||||
|
throw new SchemaExportError('VIEW_DEPENDENCY_CYCLE', 'Schema 视图依赖存在循环');
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(content) {
|
||||||
|
return createHash('sha256').update(content, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只屏蔽 SQL 字符串字面量,保留标识符参与 DEFINER 和凭据扫描。 */
|
||||||
|
function maskStringLiterals(sql) {
|
||||||
|
return String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTableAutoIncrementOutsideLiterals(sql) {
|
||||||
|
const literals = [];
|
||||||
|
const masked = String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, (literal) => {
|
||||||
|
const index = literals.push(literal) - 1;
|
||||||
|
return `\u0001${index}\u0002`;
|
||||||
|
});
|
||||||
|
const cleaned = masked.replace(
|
||||||
|
/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return cleaned.replace(/\u0001(\d+)\u0002/g, (_match, index) => literals[Number(index)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHOW CREATE 的格式由服务端决定,这里只移除与结构无关的账号绑定、当前表自增值、
|
||||||
|
* 行尾分号和换行差异,不重排对象体,也不删除字符串字面量中的业务文本。
|
||||||
|
*/
|
||||||
|
export function normalizeCreateDefinition(definition) {
|
||||||
|
const normalized = String(definition)
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(
|
||||||
|
/^(CREATE\s+(?:ALGORITHM=\S+\s+)?)DEFINER=`(?:``|[^`])+`@`(?:`(?:``|[^`])+`|'(?:''|[^'])+'|\S+)\s+/i,
|
||||||
|
'$1',
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.replace(/;+\s*$/, '');
|
||||||
|
const withoutCurrentAutoIncrement = removeTableAutoIncrementOutsideLiterals(normalized);
|
||||||
|
const inspectable = maskStringLiterals(withoutCurrentAutoIncrement);
|
||||||
|
if (
|
||||||
|
/\bDEFINER\s*=/i.test(inspectable) ||
|
||||||
|
/\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i.test(
|
||||||
|
inspectable,
|
||||||
|
) ||
|
||||||
|
/\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable) ||
|
||||||
|
/(?:--|\/\*)\s*(?:host|hostname|server)\s*[:=]\s*[^\s*]+/i.test(inspectable)
|
||||||
|
) {
|
||||||
|
throw new SchemaExportError('UNSAFE_DEFINITION', 'Schema 对象定义包含禁止内容');
|
||||||
|
}
|
||||||
|
return withoutCurrentAutoIncrement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDatabaseQueryError(error) {
|
||||||
|
if (error instanceof SchemaExportError) throw error;
|
||||||
|
throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMysql57ServerMetadata(
|
||||||
|
connection,
|
||||||
|
database,
|
||||||
|
limits = DEFAULT_EXPORT_LIMITS,
|
||||||
|
) {
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
[rows] = await connection.query({
|
||||||
|
sql: SERVER_METADATA_QUERY,
|
||||||
|
values: [database],
|
||||||
|
timeout: limits.queryTimeoutMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sanitizeDatabaseQueryError(error);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rows) || rows.length !== 1) {
|
||||||
|
throw new SchemaExportError('DATABASE_METADATA_INVALID', '开发库元数据查询结果无效');
|
||||||
|
}
|
||||||
|
const metadata = {
|
||||||
|
serverVersion: rows[0].SERVER_VERSION,
|
||||||
|
databaseCharset: rows[0].DATABASE_CHARSET,
|
||||||
|
databaseCollation: rows[0].DATABASE_COLLATION,
|
||||||
|
};
|
||||||
|
assertMysql57ServerMetadata(metadata);
|
||||||
|
return Object.freeze(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreateDescriptors(catalog) {
|
||||||
|
const viewOrder = orderViewNames(catalog);
|
||||||
|
const viewDefinitions = new Map((catalog.views ?? []).map((name) => [name, { name, type: 'VIEW' }]));
|
||||||
|
return [
|
||||||
|
...(catalog.tables ?? []).map((name) => ({ name, type: 'TABLE' })),
|
||||||
|
...(catalog.routines ?? []).map(({ name, type }) => ({ name, type })),
|
||||||
|
...viewOrder.map((name) => viewDefinitions.get(name)),
|
||||||
|
...(catalog.triggers ?? []).map(({ name }) => ({ name, type: 'TRIGGER' })),
|
||||||
|
...(catalog.events ?? []).map((name) => ({ name, type: 'EVENT' })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCreateDefinition(connection, descriptor, limits) {
|
||||||
|
const sql = `SHOW CREATE ${descriptor.type} ${quoteIdentifier(DEVELOPMENT_DATABASE)}.${quoteIdentifier(descriptor.name)}`;
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
[rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs });
|
||||||
|
} catch (error) {
|
||||||
|
sanitizeDatabaseQueryError(error);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
throw new SchemaExportError('SHOW_CREATE_EMPTY', 'SHOW CREATE 未返回对象定义');
|
||||||
|
}
|
||||||
|
const rawDefinition = rows[0]?.[CREATE_RESULT_COLUMNS[descriptor.type]];
|
||||||
|
if (typeof rawDefinition !== 'string' || rawDefinition.length === 0) {
|
||||||
|
throw new SchemaExportError('SHOW_CREATE_SHAPE_INVALID', 'SHOW CREATE 返回列无效');
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(rawDefinition, 'utf8') > limits.maxDefinitionBytes) {
|
||||||
|
throw new SchemaExportError('DEFINITION_LIMIT_EXCEEDED', 'Schema 对象定义超过安全上限');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...descriptor,
|
||||||
|
definition: normalizeCreateDefinition(rawDefinition),
|
||||||
|
sourceSha256: sha256(rawDefinition.replace(/\r\n?/g, '\n')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readDefinitions(connection, catalog, limits = DEFAULT_EXPORT_LIMITS) {
|
||||||
|
const definitions = [];
|
||||||
|
for (const descriptor of getCreateDescriptors(catalog)) {
|
||||||
|
definitions.push(await readCreateDefinition(connection, descriptor, limits));
|
||||||
|
}
|
||||||
|
return Object.freeze(definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDefinition(parts, definition) {
|
||||||
|
if (['TABLE', 'VIEW'].includes(definition.type)) {
|
||||||
|
parts.push(`${definition.definition};`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parts.push(`DELIMITER ;;\n${definition.definition} ;;\nDELIMITER ;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定恢复顺序和会话保护,不重新格式化对象体。 */
|
||||||
|
export function renderSchemaSql(catalog, definitions) {
|
||||||
|
const definitionsByKey = new Map(
|
||||||
|
definitions.map((definition) => [`${definition.type}:${definition.name}`, definition]),
|
||||||
|
);
|
||||||
|
const parts = [
|
||||||
|
'SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;',
|
||||||
|
'SET NAMES utf8mb4;',
|
||||||
|
];
|
||||||
|
for (const descriptor of getCreateDescriptors(catalog)) {
|
||||||
|
const definition = definitionsByKey.get(`${descriptor.type}:${descriptor.name}`);
|
||||||
|
if (!definition) {
|
||||||
|
throw new SchemaExportError('SHOW_CREATE_SHAPE_INVALID', 'Schema 对象定义集合不完整');
|
||||||
|
}
|
||||||
|
appendDefinition(parts, definition);
|
||||||
|
}
|
||||||
|
parts.push('SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;');
|
||||||
|
return `${parts.join('\n\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWithinDeadline(startedAt, now, limits) {
|
||||||
|
if (now() - startedAt > limits.totalTimeoutMs) {
|
||||||
|
throw new SchemaExportError('EXPORT_TIMEOUT', 'Schema 导出超过总时长限制');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildObjectCounts(catalog) {
|
||||||
|
return Object.freeze({
|
||||||
|
tables: catalog.tables.length,
|
||||||
|
views: catalog.views.length,
|
||||||
|
triggers: catalog.triggers.length,
|
||||||
|
routines: catalog.routines.length,
|
||||||
|
events: catalog.events.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单连接执行两轮目录和定义读取;任何漂移都失败。底层数据库异常统一脱敏,连接在
|
||||||
|
* 成功、查询失败、校验失败和超时路径均只关闭一次。
|
||||||
|
*/
|
||||||
|
export async function exportMysql57Schema({
|
||||||
|
config,
|
||||||
|
createConnection = mysql.createConnection,
|
||||||
|
now = Date.now,
|
||||||
|
limits = DEFAULT_EXPORT_LIMITS,
|
||||||
|
}) {
|
||||||
|
const startedAt = now();
|
||||||
|
let connection;
|
||||||
|
let result;
|
||||||
|
let failure;
|
||||||
|
try {
|
||||||
|
connection = await createConnection(buildConnectionOptions(config));
|
||||||
|
const guardedConnection = {
|
||||||
|
async query(options) {
|
||||||
|
assertWithinDeadline(startedAt, now, limits);
|
||||||
|
const queryResult = await connection.query(options);
|
||||||
|
assertWithinDeadline(startedAt, now, limits);
|
||||||
|
return queryResult;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const serverMetadata = await readMysql57ServerMetadata(
|
||||||
|
guardedConnection,
|
||||||
|
config.MYSQL_DATABASE,
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
const beforeCatalog = await readSchemaCatalog(
|
||||||
|
guardedConnection,
|
||||||
|
config.MYSQL_DATABASE,
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
const beforeDefinitions = await readDefinitions(guardedConnection, beforeCatalog, limits);
|
||||||
|
const afterCatalog = await readSchemaCatalog(
|
||||||
|
guardedConnection,
|
||||||
|
config.MYSQL_DATABASE,
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
if (sha256(JSON.stringify(beforeCatalog)) !== sha256(JSON.stringify(afterCatalog))) {
|
||||||
|
throw new SchemaExportError(
|
||||||
|
'CATALOG_CHANGED_DURING_EXPORT',
|
||||||
|
'Schema 对象目录在导出期间发生变化',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const afterDefinitions = await readDefinitions(guardedConnection, afterCatalog, limits);
|
||||||
|
if (sha256(JSON.stringify(beforeDefinitions)) !== sha256(JSON.stringify(afterDefinitions))) {
|
||||||
|
throw new SchemaExportError(
|
||||||
|
'DEFINITION_CHANGED_DURING_EXPORT',
|
||||||
|
'Schema 对象定义在导出期间发生变化',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertWithinDeadline(startedAt, now, limits);
|
||||||
|
const objectCounts = buildObjectCounts(beforeCatalog);
|
||||||
|
result = Object.freeze({
|
||||||
|
metadata: Object.freeze({
|
||||||
|
exporterName: MYSQL2_EXPORTER_METADATA.name,
|
||||||
|
exporterVersion: MYSQL2_EXPORTER_METADATA.version,
|
||||||
|
driverName: MYSQL2_EXPORTER_METADATA.driverName,
|
||||||
|
driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion,
|
||||||
|
...serverMetadata,
|
||||||
|
}),
|
||||||
|
rawSql: Object.values(objectCounts).every((count) => count === 0)
|
||||||
|
? ''
|
||||||
|
: renderSchemaSql(beforeCatalog, beforeDefinitions),
|
||||||
|
objectCounts,
|
||||||
|
durationMs: now() - startedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
failure =
|
||||||
|
error instanceof SchemaExportError
|
||||||
|
? error
|
||||||
|
: new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
|
||||||
|
} finally {
|
||||||
|
if (connection) {
|
||||||
|
try {
|
||||||
|
await connection.end();
|
||||||
|
} catch {
|
||||||
|
if (!failure) {
|
||||||
|
failure = new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 连接关闭失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failure) throw failure;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_EXPORT_LIMITS,
|
||||||
|
MYSQL2_EXPORTER_METADATA,
|
||||||
|
SchemaExportError,
|
||||||
|
assertMysql57ServerMetadata,
|
||||||
|
buildConnectionOptions,
|
||||||
|
exportMysql57Schema,
|
||||||
|
normalizeCreateDefinition,
|
||||||
|
orderViewNames,
|
||||||
|
quoteIdentifier,
|
||||||
|
readDefinitions,
|
||||||
|
readMysql57ServerMetadata,
|
||||||
|
readSchemaCatalog,
|
||||||
|
renderSchemaSql,
|
||||||
|
} 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',
|
||||||
|
'5.7.42-MariaDB',
|
||||||
|
'10.6.18-MariaDB',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => assertMysql57ServerMetadata({ ...valid, serverVersion }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createCatalogConnection(overrides = {}) {
|
||||||
|
const rowsBySource = {
|
||||||
|
'information_schema.TABLES': [
|
||||||
|
{ TABLE_NAME: 'market_snapshot', TABLE_TYPE: 'BASE TABLE' },
|
||||||
|
{ TABLE_NAME: 'market_dashboard', TABLE_TYPE: 'VIEW' },
|
||||||
|
{ TABLE_NAME: 'market_summary', TABLE_TYPE: 'VIEW' },
|
||||||
|
],
|
||||||
|
'information_schema.VIEW_TABLE_USAGE': [
|
||||||
|
{
|
||||||
|
VIEW_NAME: 'market_dashboard',
|
||||||
|
REFERENCED_SCHEMA_NAME: 'a_share_analysis_dev',
|
||||||
|
REFERENCED_TABLE_NAME: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VIEW_NAME: 'market_summary',
|
||||||
|
REFERENCED_SCHEMA_NAME: 'a_share_analysis_dev',
|
||||||
|
REFERENCED_TABLE_NAME: 'market_snapshot',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'information_schema.ROUTINES': [
|
||||||
|
{ ROUTINE_NAME: 'calculate_breadth', ROUTINE_TYPE: 'FUNCTION' },
|
||||||
|
{ ROUTINE_NAME: 'refresh_market_snapshot', ROUTINE_TYPE: 'PROCEDURE' },
|
||||||
|
],
|
||||||
|
'information_schema.TRIGGERS': [
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'snapshot_before_insert',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'information_schema.EVENTS': [{ EVENT_NAME: 'purge_market_snapshot' }],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async query(options) {
|
||||||
|
calls.push(options);
|
||||||
|
const source = Object.keys(rowsBySource).find((candidate) => options.sql.includes(candidate));
|
||||||
|
assert.ok(source, `未识别的目录查询:${options.sql}`);
|
||||||
|
return [rowsBySource[source], []];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('目录查询使用固定参数和超时并返回稳定对象', async () => {
|
||||||
|
const connection = createCatalogConnection();
|
||||||
|
const catalog = await readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS);
|
||||||
|
|
||||||
|
assert.deepEqual(catalog, {
|
||||||
|
tables: ['market_snapshot'],
|
||||||
|
views: ['market_dashboard', 'market_summary'],
|
||||||
|
viewDependencies: [
|
||||||
|
{
|
||||||
|
view: 'market_dashboard',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: 'market_summary',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_snapshot',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routines: [
|
||||||
|
{ name: 'calculate_breadth', type: 'FUNCTION' },
|
||||||
|
{ name: 'refresh_market_snapshot', type: 'PROCEDURE' },
|
||||||
|
],
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
name: 'snapshot_before_insert',
|
||||||
|
table: 'market_snapshot',
|
||||||
|
timing: 'BEFORE',
|
||||||
|
event: 'INSERT',
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: ['purge_market_snapshot'],
|
||||||
|
});
|
||||||
|
assert.equal(connection.calls.length, 5);
|
||||||
|
for (const call of connection.calls) {
|
||||||
|
assert.deepEqual(call.values, ['a_share_analysis_dev']);
|
||||||
|
assert.equal(call.timeout, DEFAULT_EXPORT_LIMITS.queryTimeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MySQL 5.7 缺少 VIEW_TABLE_USAGE 时从 VIEWS 定义解析依赖', async () => {
|
||||||
|
const fallback = createCatalogConnection();
|
||||||
|
const connection = {
|
||||||
|
calls: fallback.calls,
|
||||||
|
async query(options) {
|
||||||
|
if (options.sql.includes('information_schema.VIEW_TABLE_USAGE')) {
|
||||||
|
throw Object.assign(new Error('unknown metadata table'), { code: 'ER_UNKNOWN_TABLE' });
|
||||||
|
}
|
||||||
|
if (options.sql.includes('information_schema.VIEWS')) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
VIEW_NAME: 'market_dashboard',
|
||||||
|
VIEW_DEFINITION:
|
||||||
|
'SELECT * FROM `a_share_analysis_dev`.`market_summary`',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VIEW_NAME: 'market_summary',
|
||||||
|
VIEW_DEFINITION: 'SELECT * FROM `market_snapshot`',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return fallback.query(options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalog = await readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS);
|
||||||
|
assert.deepEqual(catalog.viewDependencies, [
|
||||||
|
{
|
||||||
|
view: 'market_dashboard',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: 'market_summary',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_snapshot',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('视图按依赖拓扑排序且同层按名称稳定排序', () => {
|
||||||
|
const catalog = {
|
||||||
|
tables: ['market_snapshot'],
|
||||||
|
views: ['z_dashboard', 'market_summary', 'a_dashboard'],
|
||||||
|
viewDependencies: [
|
||||||
|
{
|
||||||
|
view: 'z_dashboard',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: 'a_dashboard',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: 'market_summary',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_snapshot',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(orderViewNames(catalog), ['market_summary', 'a_dashboard', 'z_dashboard']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('视图排序拒绝循环、跨库和未知依赖', () => {
|
||||||
|
const base = { tables: ['market_snapshot'], views: ['first_view', 'second_view'] };
|
||||||
|
const invalidCases = [
|
||||||
|
{
|
||||||
|
code: 'VIEW_DEPENDENCY_CYCLE',
|
||||||
|
dependencies: [
|
||||||
|
{ view: 'first_view', referencedSchema: 'a_share_analysis_dev', referencedObject: 'second_view' },
|
||||||
|
{ view: 'second_view', referencedSchema: 'a_share_analysis_dev', referencedObject: 'first_view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'CROSS_SCHEMA_VIEW_DEPENDENCY',
|
||||||
|
dependencies: [
|
||||||
|
{ view: 'first_view', referencedSchema: 'production', referencedObject: 'market_snapshot' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'UNKNOWN_VIEW_DEPENDENCY',
|
||||||
|
dependencies: [
|
||||||
|
{ view: 'first_view', referencedSchema: 'a_share_analysis_dev', referencedObject: 'missing_object' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const invalid of invalidCases) {
|
||||||
|
assert.throws(
|
||||||
|
() => orderViewNames({ ...base, viewDependencies: invalid.dependencies }),
|
||||||
|
(error) => error instanceof SchemaExportError && error.code === invalid.code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目录拒绝未知表类型和超过对象上限', async () => {
|
||||||
|
const unknownType = createCatalogConnection({
|
||||||
|
'information_schema.TABLES': [{ TABLE_NAME: 'temporary_data', TABLE_TYPE: 'SYSTEM VIEW' }],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => readSchemaCatalog(unknownType, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
||||||
|
(error) => error instanceof SchemaExportError && error.code === 'UNKNOWN_TABLE_TYPE',
|
||||||
|
);
|
||||||
|
|
||||||
|
const overLimit = createCatalogConnection({
|
||||||
|
'information_schema.TABLES': [
|
||||||
|
{ TABLE_NAME: 'first_table', TABLE_TYPE: 'BASE TABLE' },
|
||||||
|
{ TABLE_NAME: 'second_table', TABLE_TYPE: 'BASE TABLE' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => readSchemaCatalog(overLimit, 'a_share_analysis_dev', { ...DEFAULT_EXPORT_LIMITS, maxObjects: 1 }),
|
||||||
|
(error) => error instanceof SchemaExportError && error.code === 'OBJECT_LIMIT_EXCEEDED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目录拒绝非法触发器时机、事件和空执行顺序', async () => {
|
||||||
|
for (const trigger of [
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'invalid_timing',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'DURING',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'invalid_event',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'MERGE',
|
||||||
|
ACTION_ORDER: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'empty_order',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'missing_order',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: null,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const connection = createCatalogConnection({
|
||||||
|
'information_schema.TRIGGERS': [trigger],
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
||||||
|
(error) => error instanceof SchemaExportError && error.code === 'CATALOG_SHAPE_INVALID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('直接目录读取将数据库异常转为固定脱敏错误', async () => {
|
||||||
|
const connection = {
|
||||||
|
async query() {
|
||||||
|
throw new Error('connect mysql://schema_reader:not-for-logs@127.0.0.1:3306/a_share_analysis_dev');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
||||||
|
(error) =>
|
||||||
|
error instanceof SchemaExportError &&
|
||||||
|
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||||
|
error.message === '开发库 Schema 查询失败',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeCatalog = Object.freeze({
|
||||||
|
tables: ['market_snapshot'],
|
||||||
|
views: ['market_dashboard', 'market_summary'],
|
||||||
|
viewDependencies: [
|
||||||
|
{
|
||||||
|
view: 'market_dashboard',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: 'market_summary',
|
||||||
|
referencedSchema: 'a_share_analysis_dev',
|
||||||
|
referencedObject: 'market_snapshot',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routines: [
|
||||||
|
{ name: 'calculate_breadth', type: 'FUNCTION' },
|
||||||
|
{ name: 'refresh_market_snapshot', type: 'PROCEDURE' },
|
||||||
|
],
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
name: 'snapshot_before_insert',
|
||||||
|
table: 'market_snapshot',
|
||||||
|
timing: 'BEFORE',
|
||||||
|
event: 'INSERT',
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: ['purge_market_snapshot'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRows = Object.freeze({
|
||||||
|
'SHOW CREATE TABLE `a_share_analysis_dev`.`market_snapshot`': {
|
||||||
|
'Create Table':
|
||||||
|
'CREATE TABLE `market_snapshot` (`id` bigint NOT NULL AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4',
|
||||||
|
},
|
||||||
|
'SHOW CREATE VIEW `a_share_analysis_dev`.`market_summary`': {
|
||||||
|
'Create View':
|
||||||
|
'CREATE ALGORITHM=UNDEFINED DEFINER=`schema_user`@`192.168.1.10` SQL SECURITY DEFINER VIEW `market_summary` AS select `market_snapshot`.`id` AS `id` from `market_snapshot`',
|
||||||
|
},
|
||||||
|
'SHOW CREATE VIEW `a_share_analysis_dev`.`market_dashboard`': {
|
||||||
|
'Create View':
|
||||||
|
'CREATE ALGORITHM=UNDEFINED DEFINER=`schema_user`@`192.168.1.10` SQL SECURITY DEFINER VIEW `market_dashboard` AS select `market_summary`.`id` AS `id` from `market_summary`',
|
||||||
|
},
|
||||||
|
'SHOW CREATE FUNCTION `a_share_analysis_dev`.`calculate_breadth`': {
|
||||||
|
'Create Function':
|
||||||
|
"CREATE DEFINER=`schema_user`@`192.168.1.10` FUNCTION `calculate_breadth`() RETURNS int RETURN 1",
|
||||||
|
},
|
||||||
|
'SHOW CREATE PROCEDURE `a_share_analysis_dev`.`refresh_market_snapshot`': {
|
||||||
|
'Create Procedure':
|
||||||
|
'CREATE DEFINER=`schema_user`@`192.168.1.10` PROCEDURE `refresh_market_snapshot`()\nBEGIN\n UPDATE `market_snapshot` SET `id` = `id`;\nEND',
|
||||||
|
},
|
||||||
|
'SHOW CREATE TRIGGER `a_share_analysis_dev`.`snapshot_before_insert`': {
|
||||||
|
'SQL Original Statement':
|
||||||
|
'CREATE DEFINER=`schema_user`@`192.168.1.10` TRIGGER `snapshot_before_insert` BEFORE INSERT ON `market_snapshot` FOR EACH ROW SET NEW.id = NEW.id',
|
||||||
|
},
|
||||||
|
'SHOW CREATE EVENT `a_share_analysis_dev`.`purge_market_snapshot`': {
|
||||||
|
'Create Event':
|
||||||
|
'CREATE DEFINER=`schema_user`@`192.168.1.10` EVENT `purge_market_snapshot` ON SCHEDULE EVERY 1 DAY DO DELETE FROM `market_snapshot` WHERE `id` < 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function catalogRowsFor(sql) {
|
||||||
|
if (sql.includes('information_schema.TABLES')) {
|
||||||
|
return [
|
||||||
|
{ TABLE_NAME: 'market_snapshot', TABLE_TYPE: 'BASE TABLE' },
|
||||||
|
{ TABLE_NAME: 'market_dashboard', TABLE_TYPE: 'VIEW' },
|
||||||
|
{ TABLE_NAME: 'market_summary', TABLE_TYPE: 'VIEW' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sql.includes('information_schema.VIEW_TABLE_USAGE')) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
VIEW_NAME: 'market_dashboard',
|
||||||
|
REFERENCED_SCHEMA_NAME: 'a_share_analysis_dev',
|
||||||
|
REFERENCED_TABLE_NAME: 'market_summary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
VIEW_NAME: 'market_summary',
|
||||||
|
REFERENCED_SCHEMA_NAME: 'a_share_analysis_dev',
|
||||||
|
REFERENCED_TABLE_NAME: 'market_snapshot',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sql.includes('information_schema.ROUTINES')) {
|
||||||
|
return [
|
||||||
|
{ ROUTINE_NAME: 'calculate_breadth', ROUTINE_TYPE: 'FUNCTION' },
|
||||||
|
{ ROUTINE_NAME: 'refresh_market_snapshot', ROUTINE_TYPE: 'PROCEDURE' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sql.includes('information_schema.TRIGGERS')) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
TRIGGER_NAME: 'snapshot_before_insert',
|
||||||
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
||||||
|
ACTION_TIMING: 'BEFORE',
|
||||||
|
EVENT_MANIPULATION: 'INSERT',
|
||||||
|
ACTION_ORDER: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sql.includes('information_schema.EVENTS')) {
|
||||||
|
return [{ EVENT_NAME: 'purge_market_snapshot' }];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExportConnection({ mutateCatalog, mutateDefinition, queryError } = {}) {
|
||||||
|
let catalogRound = 0;
|
||||||
|
let definitionRound = 0;
|
||||||
|
let tableCatalogCalls = 0;
|
||||||
|
let tableDefinitionCalls = 0;
|
||||||
|
const connection = {
|
||||||
|
endCalls: 0,
|
||||||
|
async query(options) {
|
||||||
|
if (queryError) throw queryError;
|
||||||
|
if (options.sql.includes('information_schema.SCHEMATA')) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
SERVER_VERSION: '5.7.42-log',
|
||||||
|
DATABASE_CHARSET: 'utf8mb4',
|
||||||
|
DATABASE_COLLATION: 'utf8mb4_general_ci',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const catalogRows = catalogRowsFor(options.sql);
|
||||||
|
if (catalogRows) {
|
||||||
|
if (options.sql.includes('information_schema.TABLES')) {
|
||||||
|
tableCatalogCalls += 1;
|
||||||
|
catalogRound = tableCatalogCalls > 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
const rows = mutateCatalog?.(options.sql, catalogRows, catalogRound) ?? catalogRows;
|
||||||
|
return [rows, []];
|
||||||
|
}
|
||||||
|
const row = createRows[options.sql];
|
||||||
|
assert.ok(row, `未识别的 SHOW CREATE:${options.sql}`);
|
||||||
|
if (options.sql.includes('SHOW CREATE TABLE')) {
|
||||||
|
tableDefinitionCalls += 1;
|
||||||
|
definitionRound = tableDefinitionCalls > 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
const changed = mutateDefinition?.(options.sql, row, definitionRound);
|
||||||
|
return [changed === undefined ? [row] : changed, []];
|
||||||
|
},
|
||||||
|
async end() {
|
||||||
|
connection.endCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('读取并校验固定开发库服务端元数据', async () => {
|
||||||
|
const connection = createExportConnection();
|
||||||
|
assert.deepEqual(
|
||||||
|
await readMysql57ServerMetadata(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
||||||
|
{
|
||||||
|
serverVersion: '5.7.42-log',
|
||||||
|
databaseCharset: 'utf8mb4',
|
||||||
|
databaseCollation: 'utf8mb4_general_ci',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SHOW CREATE 覆盖全部对象并按可恢复顺序渲染', async () => {
|
||||||
|
const connection = createExportConnection();
|
||||||
|
const definitions = await readDefinitions(connection, completeCatalog, DEFAULT_EXPORT_LIMITS);
|
||||||
|
const sql = renderSchemaSql(completeCatalog, definitions);
|
||||||
|
|
||||||
|
const order = [
|
||||||
|
'CREATE TABLE `market_snapshot`',
|
||||||
|
'FUNCTION `calculate_breadth`',
|
||||||
|
'PROCEDURE `refresh_market_snapshot`',
|
||||||
|
'VIEW `market_summary`',
|
||||||
|
'VIEW `market_dashboard`',
|
||||||
|
'TRIGGER `snapshot_before_insert`',
|
||||||
|
'EVENT `purge_market_snapshot`',
|
||||||
|
].map((fragment) => sql.indexOf(fragment));
|
||||||
|
assert.ok(order.every((index) => index >= 0));
|
||||||
|
assert.deepEqual(order, [...order].sort((left, right) => left - right));
|
||||||
|
assert.match(sql, /DELIMITER ;;\nCREATE PROCEDURE[\s\S]+END ;;\nDELIMITER ;/);
|
||||||
|
assert.doesNotMatch(sql, /DEFINER\s*=/i);
|
||||||
|
assert.doesNotMatch(sql, /AUTO_INCREMENT=12/);
|
||||||
|
assert.match(sql, /`id` bigint NOT NULL AUTO_INCREMENT/);
|
||||||
|
assert.match(sql, /UPDATE `market_snapshot`/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('定义规范化只移除账号绑定和当前自增值', () => {
|
||||||
|
const routine = normalizeCreateDefinition(
|
||||||
|
"CREATE DEFINER=`user`@`host` PROCEDURE `sample`() SELECT 'DEFINER=`kept`@`text` AUTO_INCREMENT=99'",
|
||||||
|
);
|
||||||
|
const table = normalizeCreateDefinition(
|
||||||
|
'CREATE TABLE `sample` (`id` bigint AUTO_INCREMENT) ENGINE=InnoDB AUTO_INCREMENT=99 DEFAULT CHARSET=utf8mb4;',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
routine,
|
||||||
|
"CREATE PROCEDURE `sample`() SELECT 'DEFINER=`kept`@`text` AUTO_INCREMENT=99'",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
table,
|
||||||
|
'CREATE TABLE `sample` (`id` bigint AUTO_INCREMENT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('规范化保留例程中的自增文本并拒绝主机信息', () => {
|
||||||
|
const routine = normalizeCreateDefinition(
|
||||||
|
"CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
routine,
|
||||||
|
"CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeCreateDefinition('CREATE TABLE `sample` (`id` int) /* Host: prod-db.internal */'),
|
||||||
|
(error) => error.code === 'UNSAFE_DEFINITION',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('完整导出双重读取一致并始终关闭连接', async () => {
|
||||||
|
const connection = createExportConnection();
|
||||||
|
let receivedOptions;
|
||||||
|
const result = await exportMysql57Schema({
|
||||||
|
config,
|
||||||
|
createConnection: async (options) => {
|
||||||
|
receivedOptions = options;
|
||||||
|
return connection;
|
||||||
|
},
|
||||||
|
now: () => 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(receivedOptions.multipleStatements, false);
|
||||||
|
assert.equal(connection.endCalls, 1);
|
||||||
|
assert.deepEqual(result.objectCounts, {
|
||||||
|
tables: 1,
|
||||||
|
views: 2,
|
||||||
|
triggers: 1,
|
||||||
|
routines: 2,
|
||||||
|
events: 1,
|
||||||
|
});
|
||||||
|
assert.equal(result.durationMs, 0);
|
||||||
|
assert.equal(result.metadata.driverName, 'mysql2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('导出拒绝目录变化和定义变化', async () => {
|
||||||
|
const catalogConnection = createExportConnection({
|
||||||
|
mutateCatalog(sql, rows, round) {
|
||||||
|
if (round === 1 && sql.includes('information_schema.TABLES')) {
|
||||||
|
return [...rows, { TABLE_NAME: 'new_table', TABLE_TYPE: 'BASE TABLE' }];
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
exportMysql57Schema({
|
||||||
|
config,
|
||||||
|
createConnection: async () => catalogConnection,
|
||||||
|
now: () => 100,
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'CATALOG_CHANGED_DURING_EXPORT',
|
||||||
|
);
|
||||||
|
assert.equal(catalogConnection.endCalls, 1);
|
||||||
|
|
||||||
|
const definitionConnection = createExportConnection({
|
||||||
|
mutateDefinition(sql, row, round) {
|
||||||
|
if (round === 1 && sql.includes('SHOW CREATE TABLE')) {
|
||||||
|
return [{ ...row, 'Create Table': `${row['Create Table']} COMMENT='changed'` }];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
exportMysql57Schema({
|
||||||
|
config,
|
||||||
|
createConnection: async () => definitionConnection,
|
||||||
|
now: () => 100,
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'DEFINITION_CHANGED_DURING_EXPORT',
|
||||||
|
);
|
||||||
|
assert.equal(definitionConnection.endCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SHOW CREATE 拒绝空结果、错误形状和过大定义', async () => {
|
||||||
|
for (const [changed, expectedCode] of [
|
||||||
|
[[], 'SHOW_CREATE_EMPTY'],
|
||||||
|
[[{ unexpected: 'value' }], 'SHOW_CREATE_SHAPE_INVALID'],
|
||||||
|
]) {
|
||||||
|
const connection = createExportConnection({
|
||||||
|
mutateDefinition(sql) {
|
||||||
|
return sql.includes('SHOW CREATE TABLE') ? changed : undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => readDefinitions(connection, completeCatalog, DEFAULT_EXPORT_LIMITS),
|
||||||
|
(error) => error.code === expectedCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversized = createExportConnection({
|
||||||
|
mutateDefinition(sql, row) {
|
||||||
|
return sql.includes('SHOW CREATE TABLE')
|
||||||
|
? [{ ...row, 'Create Table': 'x'.repeat(100) }]
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
readDefinitions(oversized, completeCatalog, {
|
||||||
|
...DEFAULT_EXPORT_LIMITS,
|
||||||
|
maxDefinitionBytes: 32,
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'DEFINITION_LIMIT_EXCEEDED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('直接元数据和 SHOW CREATE 查询错误统一脱敏且保留既有业务错误', async () => {
|
||||||
|
const rawFailure = new Error('host=prod-db.internal password=not-for-logs');
|
||||||
|
await assert.rejects(
|
||||||
|
() => readMysql57ServerMetadata({ query: async () => { throw rawFailure; } }, 'a_share_analysis_dev'),
|
||||||
|
(error) =>
|
||||||
|
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||||
|
!error.message.includes('prod-db.internal') &&
|
||||||
|
!error.message.includes('not-for-logs'),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => readDefinitions({ query: async () => { throw rawFailure; } }, completeCatalog),
|
||||||
|
(error) =>
|
||||||
|
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||||
|
!error.message.includes('prod-db.internal') &&
|
||||||
|
!error.message.includes('not-for-logs'),
|
||||||
|
);
|
||||||
|
const timeout = new SchemaExportError('EXPORT_TIMEOUT', 'Schema 导出超过总时长限制');
|
||||||
|
await assert.rejects(
|
||||||
|
() => readDefinitions({ query: async () => { throw timeout; } }, completeCatalog),
|
||||||
|
(error) => error === timeout,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('查询失败和总超时只返回脱敏错误并关闭连接', async () => {
|
||||||
|
const failed = createExportConnection({
|
||||||
|
queryError: new Error('connect 127.0.0.1 password=not-for-logs'),
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
exportMysql57Schema({
|
||||||
|
config,
|
||||||
|
createConnection: async () => failed,
|
||||||
|
now: () => 100,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||||
|
!error.message.includes('127.0.0.1') &&
|
||||||
|
!error.message.includes('not-for-logs'),
|
||||||
|
);
|
||||||
|
assert.equal(failed.endCalls, 1);
|
||||||
|
|
||||||
|
const timedOut = createExportConnection();
|
||||||
|
let tick = 0;
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
exportMysql57Schema({
|
||||||
|
config,
|
||||||
|
createConnection: async () => timedOut,
|
||||||
|
now: () => (tick++ === 0 ? 0 : 30_001),
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'EXPORT_TIMEOUT',
|
||||||
|
);
|
||||||
|
assert.equal(timedOut.endCalls, 1);
|
||||||
|
});
|
||||||
+260
-114
@@ -12,12 +12,20 @@ import { createHash } from 'node:crypto';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MYSQL2_EXPORTER_METADATA,
|
||||||
|
assertMysql57ServerMetadata as assertMysql57ServerMetadataV3,
|
||||||
|
exportMysql57Schema,
|
||||||
|
} from './mysql2-schema-exporter.mjs';
|
||||||
|
|
||||||
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
||||||
const REQUIRED_CHARSET = 'utf8mb4';
|
const REQUIRED_CHARSET = 'utf8mb4';
|
||||||
const REQUIRED_COLLATION = 'utf8mb4_general_ci';
|
const REQUIRED_COLLATION = 'utf8mb4_general_ci';
|
||||||
const BASELINE_FORMAT_VERSION = 2;
|
const BASELINE_FORMAT_VERSION = 3;
|
||||||
|
const PREVIOUS_BASELINE_FORMAT_VERSION = 2;
|
||||||
const LEGACY_BASELINE_FORMAT_VERSION = 1;
|
const LEGACY_BASELINE_FORMAT_VERSION = 1;
|
||||||
const TOOL_VERSION = '2.0.0';
|
const TOOL_VERSION = '3.0.0';
|
||||||
|
const PREVIOUS_TOOL_VERSION = '2.0.0';
|
||||||
const LEGACY_TOOL_VERSION = '1.0.0';
|
const LEGACY_TOOL_VERSION = '1.0.0';
|
||||||
const VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
|
const VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
|
||||||
const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '112.192.20.11']);
|
const DEVELOPMENT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '112.192.20.11']);
|
||||||
@@ -73,8 +81,8 @@ export function assertSafeDevelopmentConfiguration(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端保持 5.7 系列以生成兼容导出;服务端固定 5.7.42,避免开发、测试和生产
|
* mysql、mysqldump 与服务端统一接受完整 5.7.x;字符集和排序规则仍按精确值
|
||||||
* 由不同补丁行为产生漂移。同时核对数据库真实字符规则,而不是信任 env 声明。
|
* 校验,避免放宽补丁版本时意外放宽数据库结构兼容边界。
|
||||||
*/
|
*/
|
||||||
export function assertMysql57Metadata(metadata) {
|
export function assertMysql57Metadata(metadata) {
|
||||||
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqlClientVersion ?? '')) {
|
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqlClientVersion ?? '')) {
|
||||||
@@ -85,8 +93,8 @@ export function assertMysql57Metadata(metadata) {
|
|||||||
`mysqldump 客户端必须为 5.7.x,实际 ${metadata.mysqldumpClientVersion || '(空)'}`,
|
`mysqldump 客户端必须为 5.7.x,实际 ${metadata.mysqldumpClientVersion || '(空)'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!/^5\.7\.42(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) {
|
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) {
|
||||||
throw new Error(`MySQL 服务端必须为 5.7.42,实际 ${metadata.serverVersion || '(空)'}`);
|
throw new Error(`MySQL 服务端必须为 5.7.x,实际 ${metadata.serverVersion || '(空)'}`);
|
||||||
}
|
}
|
||||||
requireExactValue(metadata.databaseCharset, REQUIRED_CHARSET, '开发数据库字符集不符合要求');
|
requireExactValue(metadata.databaseCharset, REQUIRED_CHARSET, '开发数据库字符集不符合要求');
|
||||||
requireExactValue(
|
requireExactValue(
|
||||||
@@ -283,54 +291,108 @@ function countSchemaObjects(sql) {
|
|||||||
function buildManifest(fields) {
|
function buildManifest(fields) {
|
||||||
// JSON 是 YAML 1.2 的严格子集;使用标准解析器生成和读取,避免手写 YAML
|
// JSON 是 YAML 1.2 的严格子集;使用标准解析器生成和读取,避免手写 YAML
|
||||||
// 对缩进、引号和同名嵌套字段产生歧义,同时仍保留约定的 manifest.yaml 文件名。
|
// 对缩进、引号和同名嵌套字段产生歧义,同时仍保留约定的 manifest.yaml 文件名。
|
||||||
return `${JSON.stringify(
|
const common = {
|
||||||
{
|
format_version: fields.formatVersion,
|
||||||
format_version: BASELINE_FORMAT_VERSION,
|
status: fields.status,
|
||||||
status: fields.status,
|
application_version: fields.applicationVersion,
|
||||||
application_version: fields.applicationVersion,
|
git_commit: fields.gitCommit,
|
||||||
git_commit: fields.gitCommit,
|
source_environment: 'development',
|
||||||
source_environment: 'development',
|
database_name: DEVELOPMENT_DATABASE,
|
||||||
database_name: DEVELOPMENT_DATABASE,
|
charset: REQUIRED_CHARSET,
|
||||||
mysql_client_version: fields.mysqlClientVersion,
|
collation: REQUIRED_COLLATION,
|
||||||
mysqldump_client_version: fields.mysqldumpClientVersion,
|
generated_at: fields.generatedAt,
|
||||||
mysql_server_version: fields.serverVersion,
|
generator: `schema-baseline/${fields.formatVersion === 3 ? TOOL_VERSION : fields.formatVersion === 2 ? PREVIOUS_TOOL_VERSION : LEGACY_TOOL_VERSION}`,
|
||||||
charset: REQUIRED_CHARSET,
|
reason: fields.reason ?? 'none',
|
||||||
collation: REQUIRED_COLLATION,
|
schema_sha256: fields.schemaSha256 ?? 'not_applicable',
|
||||||
generated_at: fields.generatedAt,
|
objects: {
|
||||||
generator: `schema-baseline/${TOOL_VERSION}`,
|
tables: fields.captured,
|
||||||
reason: fields.reason ?? 'none',
|
views: fields.captured,
|
||||||
schema_sha256: fields.schemaSha256 ?? 'not_applicable',
|
triggers: fields.captured,
|
||||||
objects: {
|
routines: fields.captured,
|
||||||
tables: fields.captured,
|
events: fields.captured,
|
||||||
views: fields.captured,
|
|
||||||
triggers: fields.captured,
|
|
||||||
routines: fields.captured,
|
|
||||||
events: fields.captured,
|
|
||||||
},
|
|
||||||
object_counts: fields.objectCounts,
|
|
||||||
},
|
},
|
||||||
null,
|
object_counts: fields.objectCounts,
|
||||||
2,
|
};
|
||||||
)}\n`;
|
if (fields.formatVersion === 3) {
|
||||||
|
common.exporter_name = fields.exporterName;
|
||||||
|
common.exporter_version = fields.exporterVersion;
|
||||||
|
common.database_driver = fields.driverName;
|
||||||
|
common.database_driver_version = fields.driverVersion;
|
||||||
|
common.mysql_server_version = fields.serverVersion;
|
||||||
|
} else {
|
||||||
|
common.mysql_client_version = fields.mysqlClientVersion;
|
||||||
|
common.mysqldump_client_version = fields.mysqldumpClientVersion;
|
||||||
|
common.mysql_server_version = fields.serverVersion;
|
||||||
|
}
|
||||||
|
return `${JSON.stringify(common, null, 2)}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCapturedArtifacts(metadata, sql) {
|
function assertNodeMetadata(metadata) {
|
||||||
|
if (metadata.formatVersion !== 3) {
|
||||||
|
throw new Error('Node Schema 基线必须使用 format v3');
|
||||||
|
}
|
||||||
|
for (const key of ['exporterName', 'exporterVersion', 'driverName', 'driverVersion']) {
|
||||||
|
if (!String(metadata[key] ?? '').trim()) {
|
||||||
|
throw new Error(`Node Schema 元数据缺少 ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertMysql57ServerMetadataV3(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCapturedArtifacts(metadata, sql, objectCounts) {
|
||||||
|
if (metadata.formatVersion === 3) {
|
||||||
|
assertNodeMetadata(metadata);
|
||||||
|
if (!objectCounts || Object.keys(objectCounts).sort().join(',') !== [...OBJECT_KEYS].sort().join(',')) {
|
||||||
|
throw new Error('Node Schema 对象计数字段无效');
|
||||||
|
}
|
||||||
|
const normalizedSql = normalizeSchemaSql(sql);
|
||||||
|
const derivedCounts = countSchemaObjects(normalizedSql);
|
||||||
|
for (const key of OBJECT_KEYS) {
|
||||||
|
if (!Number.isInteger(objectCounts[key]) || objectCounts[key] < 0 || objectCounts[key] !== derivedCounts[key]) {
|
||||||
|
throw new Error(`Node Schema 对象计数与 SQL 不一致:${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (String(sql).trim() && Object.values(objectCounts).every((count) => count === 0)) {
|
||||||
|
throw new Error('Node Schema SQL 非空但对象计数全为零');
|
||||||
|
}
|
||||||
|
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`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
assertMysql57Metadata(metadata);
|
assertMysql57Metadata(metadata);
|
||||||
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
||||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||||
}
|
}
|
||||||
const normalizedSql = normalizeSchemaSql(sql);
|
const normalizedSql = normalizeSchemaSql(sql);
|
||||||
const objectCounts = countSchemaObjects(normalizedSql);
|
const derivedCounts = countSchemaObjects(normalizedSql);
|
||||||
// mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。
|
// mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。
|
||||||
if (Object.values(objectCounts).every((count) => count === 0)) {
|
if (Object.values(derivedCounts).every((count) => count === 0)) {
|
||||||
return buildCapturedEmptyArtifacts(metadata);
|
return buildCapturedEmptyArtifacts(metadata);
|
||||||
}
|
}
|
||||||
const schemaSha256 = sha256(normalizedSql);
|
const schemaSha256 = sha256(normalizedSql);
|
||||||
const manifest = buildManifest({
|
const manifest = buildManifest({
|
||||||
...metadata,
|
...metadata,
|
||||||
|
formatVersion: metadata.formatVersion ?? PREVIOUS_BASELINE_FORMAT_VERSION,
|
||||||
status: 'captured',
|
status: 'captured',
|
||||||
captured: true,
|
captured: true,
|
||||||
objectCounts,
|
objectCounts: derivedCounts,
|
||||||
schemaSha256,
|
schemaSha256,
|
||||||
});
|
});
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -345,6 +407,23 @@ export function buildCapturedArtifacts(metadata, sql) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCapturedEmptyArtifacts(metadata) {
|
export function buildCapturedEmptyArtifacts(metadata) {
|
||||||
|
if (metadata.formatVersion === 3) {
|
||||||
|
assertNodeMetadata(metadata);
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
assertMysql57Metadata(metadata);
|
assertMysql57Metadata(metadata);
|
||||||
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
||||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||||
@@ -353,6 +432,7 @@ export function buildCapturedEmptyArtifacts(metadata) {
|
|||||||
const objectCounts = Object.freeze(Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])));
|
const objectCounts = Object.freeze(Object.fromEntries(OBJECT_KEYS.map((key) => [key, 0])));
|
||||||
const manifest = buildManifest({
|
const manifest = buildManifest({
|
||||||
...metadata,
|
...metadata,
|
||||||
|
formatVersion: metadata.formatVersion ?? PREVIOUS_BASELINE_FORMAT_VERSION,
|
||||||
status: 'captured_empty',
|
status: 'captured_empty',
|
||||||
captured: false,
|
captured: false,
|
||||||
objectCounts,
|
objectCounts,
|
||||||
@@ -369,8 +449,14 @@ export function buildNotApplicableArtifacts(metadata) {
|
|||||||
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
||||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||||
}
|
}
|
||||||
const manifest = buildManifest({
|
const nodeMetadata = metadata.formatVersion === 3 ? metadata : {
|
||||||
...metadata,
|
...metadata,
|
||||||
|
formatVersion: PREVIOUS_BASELINE_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
const manifest = buildManifest({
|
||||||
|
...nodeMetadata,
|
||||||
|
...metadata,
|
||||||
|
formatVersion: nodeMetadata.formatVersion,
|
||||||
mysqlClientVersion: 'not_enabled',
|
mysqlClientVersion: 'not_enabled',
|
||||||
mysqldumpClientVersion: 'not_enabled',
|
mysqldumpClientVersion: 'not_enabled',
|
||||||
serverVersion: 'not_enabled',
|
serverVersion: 'not_enabled',
|
||||||
@@ -403,28 +489,65 @@ function compareExistingArtifacts(targetDirectory, artifacts) {
|
|||||||
* 先在目标父目录写入临时目录,再原子改名。这样异常不会留下半份基线;
|
* 先在目标父目录写入临时目录,再原子改名。这样异常不会留下半份基线;
|
||||||
* 已发布版本目录只允许逐字节相同的幂等重跑,任何差异都必须人工留痕修正。
|
* 已发布版本目录只允许逐字节相同的幂等重跑,任何差异都必须人工留痕修正。
|
||||||
*/
|
*/
|
||||||
export function persistBaselineArtifacts(targetDirectory, artifacts) {
|
const DEFAULT_FILE_OPERATIONS = Object.freeze({
|
||||||
if (existsSync(targetDirectory)) {
|
existsSync,
|
||||||
if (compareExistingArtifacts(targetDirectory, artifacts)) {
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function persistBaselineArtifacts(
|
||||||
|
targetDirectory,
|
||||||
|
artifacts,
|
||||||
|
fileOperations = DEFAULT_FILE_OPERATIONS,
|
||||||
|
) {
|
||||||
|
const operations = { ...DEFAULT_FILE_OPERATIONS, ...fileOperations };
|
||||||
|
const {
|
||||||
|
existsSync: fileExists,
|
||||||
|
mkdirSync: makeDirectory,
|
||||||
|
readFileSync: readFile,
|
||||||
|
readdirSync: readDirectory,
|
||||||
|
renameSync: renameFile,
|
||||||
|
rmSync: removePath,
|
||||||
|
writeFileSync: writeFile,
|
||||||
|
} = operations;
|
||||||
|
const compare = () => {
|
||||||
|
const actualFiles = readDirectory(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) => readFile(path.join(targetDirectory, file), 'utf8') === artifacts.files[file],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (fileExists(targetDirectory)) {
|
||||||
|
if (compare()) {
|
||||||
return { reused: true };
|
return { reused: true };
|
||||||
}
|
}
|
||||||
throw new Error(`目标版本基线已存在且内容不同,禁止覆盖:${targetDirectory}`);
|
throw new Error(`目标版本基线已存在且内容不同,禁止覆盖:${targetDirectory}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentDirectory = path.dirname(targetDirectory);
|
const parentDirectory = path.dirname(targetDirectory);
|
||||||
mkdirSync(parentDirectory, { recursive: true });
|
makeDirectory(parentDirectory, { recursive: true });
|
||||||
const temporaryDirectory = path.join(
|
const temporaryDirectory = path.join(
|
||||||
parentDirectory,
|
parentDirectory,
|
||||||
`.${path.basename(targetDirectory)}.tmp-${process.pid}-${Date.now()}`,
|
`.${path.basename(targetDirectory)}.tmp-${process.pid}-${Date.now()}`,
|
||||||
);
|
);
|
||||||
mkdirSync(temporaryDirectory);
|
makeDirectory(temporaryDirectory);
|
||||||
try {
|
try {
|
||||||
for (const [file, content] of Object.entries(artifacts.files)) {
|
for (const [file, content] of Object.entries(artifacts.files)) {
|
||||||
writeFileSync(path.join(temporaryDirectory, file), content, 'utf8');
|
writeFile(path.join(temporaryDirectory, file), content, 'utf8');
|
||||||
}
|
}
|
||||||
renameSync(temporaryDirectory, targetDirectory);
|
renameFile(temporaryDirectory, targetDirectory);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rmSync(temporaryDirectory, { recursive: true, force: true });
|
removePath(temporaryDirectory, { recursive: true, force: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { reused: false };
|
return { reused: false };
|
||||||
@@ -468,6 +591,8 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
const supportedFormat =
|
const supportedFormat =
|
||||||
(manifest.format_version === LEGACY_BASELINE_FORMAT_VERSION &&
|
(manifest.format_version === LEGACY_BASELINE_FORMAT_VERSION &&
|
||||||
manifest.generator === `schema-baseline/${LEGACY_TOOL_VERSION}`) ||
|
manifest.generator === `schema-baseline/${LEGACY_TOOL_VERSION}`) ||
|
||||||
|
(manifest.format_version === PREVIOUS_BASELINE_FORMAT_VERSION &&
|
||||||
|
manifest.generator === `schema-baseline/${PREVIOUS_TOOL_VERSION}`) ||
|
||||||
(manifest.format_version === BASELINE_FORMAT_VERSION &&
|
(manifest.format_version === BASELINE_FORMAT_VERSION &&
|
||||||
manifest.generator === `schema-baseline/${TOOL_VERSION}`);
|
manifest.generator === `schema-baseline/${TOOL_VERSION}`);
|
||||||
if (
|
if (
|
||||||
@@ -506,30 +631,49 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
assertExactFileSet(targetDirectory, NOT_APPLICABLE_FILES);
|
assertExactFileSet(targetDirectory, NOT_APPLICABLE_FILES);
|
||||||
const reason = manifest.reason;
|
const reason = manifest.reason;
|
||||||
requireExactValue(reason, 'mysql_disabled', 'Schema 不适用原因无效');
|
requireExactValue(reason, 'mysql_disabled', 'Schema 不适用原因无效');
|
||||||
if (
|
const commonInvalid =
|
||||||
manifest.mysql_client_version !== 'not_enabled' ||
|
|
||||||
manifest.mysqldump_client_version !== 'not_enabled' ||
|
|
||||||
manifest.mysql_server_version !== 'not_enabled' ||
|
manifest.mysql_server_version !== 'not_enabled' ||
|
||||||
manifest.schema_sha256 !== 'not_applicable' ||
|
manifest.schema_sha256 !== 'not_applicable' ||
|
||||||
Object.keys(manifest.objects ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',') ||
|
Object.keys(manifest.objects ?? {}).sort().join(',') !==
|
||||||
OBJECT_KEYS.some((key) => manifest.objects[key] !== false)
|
[...OBJECT_KEYS].sort().join(',') ||
|
||||||
) {
|
OBJECT_KEYS.some((key) => manifest.objects[key] !== false);
|
||||||
|
const formatInvalid =
|
||||||
|
manifest.format_version === BASELINE_FORMAT_VERSION
|
||||||
|
? !String(manifest.exporter_name ?? '').trim() ||
|
||||||
|
!String(manifest.exporter_version ?? '').trim() ||
|
||||||
|
!String(manifest.database_driver ?? '').trim() ||
|
||||||
|
!String(manifest.database_driver_version ?? '').trim() ||
|
||||||
|
'mysql_client_version' in manifest ||
|
||||||
|
'mysqldump_client_version' in manifest
|
||||||
|
: manifest.mysql_client_version !== 'not_enabled' ||
|
||||||
|
manifest.mysqldump_client_version !== 'not_enabled';
|
||||||
|
if (commonInvalid || formatInvalid) {
|
||||||
throw new Error('Schema 不适用清单必须证明 MySQL 未启用且所有对象均未捕获');
|
throw new Error('Schema 不适用清单必须证明 MySQL 未启用且所有对象均未捕获');
|
||||||
}
|
}
|
||||||
return { status, applicationVersion, reason };
|
return { status, applicationVersion, reason };
|
||||||
}
|
}
|
||||||
if (status === 'captured_empty') {
|
if (status === 'captured_empty') {
|
||||||
if (manifest.format_version !== BASELINE_FORMAT_VERSION) {
|
|
||||||
throw new Error('空库 Schema 基线只允许 format v2');
|
|
||||||
}
|
|
||||||
assertExactFileSet(targetDirectory, CAPTURED_EMPTY_FILES);
|
assertExactFileSet(targetDirectory, CAPTURED_EMPTY_FILES);
|
||||||
assertMysql57Metadata({
|
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
|
||||||
mysqlClientVersion: manifest.mysql_client_version,
|
assertNodeMetadata({
|
||||||
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
formatVersion: 3,
|
||||||
serverVersion: manifest.mysql_server_version,
|
exporterName: manifest.exporter_name,
|
||||||
databaseCharset: manifest.charset,
|
exporterVersion: manifest.exporter_version,
|
||||||
databaseCollation: manifest.collation,
|
driverName: manifest.database_driver,
|
||||||
});
|
driverVersion: manifest.database_driver_version,
|
||||||
|
serverVersion: manifest.mysql_server_version,
|
||||||
|
databaseCharset: manifest.charset,
|
||||||
|
databaseCollation: manifest.collation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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('');
|
const expectedEmptyChecksum = sha256('');
|
||||||
if (
|
if (
|
||||||
manifest.schema_sha256 !== expectedEmptyChecksum ||
|
manifest.schema_sha256 !== expectedEmptyChecksum ||
|
||||||
@@ -546,13 +690,26 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertExactFileSet(targetDirectory, CAPTURED_FILES);
|
assertExactFileSet(targetDirectory, CAPTURED_FILES);
|
||||||
assertMysql57Metadata({
|
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
|
||||||
mysqlClientVersion: manifest.mysql_client_version,
|
assertNodeMetadata({
|
||||||
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
formatVersion: 3,
|
||||||
serverVersion: manifest.mysql_server_version,
|
exporterName: manifest.exporter_name,
|
||||||
databaseCharset: manifest.charset,
|
exporterVersion: manifest.exporter_version,
|
||||||
databaseCollation: manifest.collation,
|
driverName: manifest.database_driver,
|
||||||
});
|
driverVersion: manifest.database_driver_version,
|
||||||
|
serverVersion: manifest.mysql_server_version,
|
||||||
|
databaseCharset: manifest.charset,
|
||||||
|
databaseCollation: manifest.collation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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(',')) {
|
if (Object.keys(manifest.objects ?? {}).sort().join(',') !== [...OBJECT_KEYS].sort().join(',')) {
|
||||||
throw new Error('Schema 对象范围字段无效');
|
throw new Error('Schema 对象范围字段无效');
|
||||||
}
|
}
|
||||||
@@ -583,6 +740,14 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
if (manifestChecksum !== schemaSha256 || checksumFile !== `${schemaSha256} schema.sql\n`) {
|
if (manifestChecksum !== schemaSha256 || checksumFile !== `${schemaSha256} schema.sql\n`) {
|
||||||
throw new Error('Schema SQL、manifest 和 checksum 校验值不一致');
|
throw new Error('Schema SQL、manifest 和 checksum 校验值不一致');
|
||||||
}
|
}
|
||||||
|
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
|
||||||
|
const derivedCounts = countSchemaObjects(sql);
|
||||||
|
for (const key of OBJECT_KEYS) {
|
||||||
|
if (manifest.object_counts[key] !== derivedCounts[key]) {
|
||||||
|
throw new Error(`Schema 对象计数与 SQL 不一致:${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return { status, applicationVersion, schemaSha256 };
|
return { status, applicationVersion, schemaSha256 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +779,7 @@ function extractClientVersion(output) {
|
|||||||
* 真实执行器可注入是为了让测试证明“关闭时零数据库调用”和命令边界;默认执行器
|
* 真实执行器可注入是为了让测试证明“关闭时零数据库调用”和命令边界;默认执行器
|
||||||
* 仍使用 execFileSync 参数数组。密码仅放入子进程环境 MYSQL_PWD,不会进入 argv。
|
* 仍使用 execFileSync 参数数组。密码仅放入子进程环境 MYSQL_PWD,不会进入 argv。
|
||||||
*/
|
*/
|
||||||
export function runBaselineGeneration(options) {
|
export async function runBaselineGeneration(options) {
|
||||||
if ((options.commandArguments ?? []).length > 0) {
|
if ((options.commandArguments ?? []).length > 0) {
|
||||||
throw new Error('Schema 基线命令不接受命令参数,不能覆盖环境、数据库或 env 路径');
|
throw new Error('Schema 基线命令不接受命令参数,不能覆盖环境、数据库或 env 路径');
|
||||||
}
|
}
|
||||||
@@ -641,52 +806,33 @@ export function runBaselineGeneration(options) {
|
|||||||
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
if (!config.MYSQL_ENABLED) {
|
if (!config.MYSQL_ENABLED) {
|
||||||
const artifacts = buildNotApplicableArtifacts(commonMetadata);
|
const artifacts = buildNotApplicableArtifacts({
|
||||||
persistBaselineArtifacts(targetDirectory, artifacts);
|
...commonMetadata,
|
||||||
|
formatVersion: BASELINE_FORMAT_VERSION,
|
||||||
|
exporterName: MYSQL2_EXPORTER_METADATA.name,
|
||||||
|
exporterVersion: MYSQL2_EXPORTER_METADATA.version,
|
||||||
|
driverName: MYSQL2_EXPORTER_METADATA.driverName,
|
||||||
|
driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion,
|
||||||
|
serverVersion: 'not_enabled',
|
||||||
|
databaseCharset: REQUIRED_CHARSET,
|
||||||
|
databaseCollation: REQUIRED_COLLATION,
|
||||||
|
});
|
||||||
|
persistBaselineArtifacts(targetDirectory, artifacts, options.fileOperations);
|
||||||
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
|
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeFile = options.executeFile ?? execFileSync;
|
const exported = await (options.exportSchema ?? exportMysql57Schema)({ config });
|
||||||
const childEnvironment = { ...process.env, MYSQL_PWD: config.MYSQL_PASSWORD ?? '' };
|
const exportedMetadata = exported.metadata ?? {};
|
||||||
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.42-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 = {
|
const metadata = {
|
||||||
...commonMetadata,
|
...commonMetadata,
|
||||||
mysqlClientVersion,
|
...exportedMetadata,
|
||||||
mysqldumpClientVersion,
|
applicationVersion: exportedMetadata.applicationVersion ?? commonMetadata.applicationVersion,
|
||||||
serverVersion: serverMetadata[0],
|
gitCommit: exportedMetadata.gitCommit ?? commonMetadata.gitCommit,
|
||||||
databaseCharset: serverMetadata[1],
|
generatedAt: exportedMetadata.generatedAt ?? commonMetadata.generatedAt,
|
||||||
databaseCollation: serverMetadata[2],
|
formatVersion: BASELINE_FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
assertMysql57Metadata(metadata);
|
const artifacts = buildCapturedArtifacts(metadata, exported.rawSql, exported.objectCounts);
|
||||||
const rawSql = executeFile('mysqldump', buildMysqldumpArguments(config), executionOptions);
|
persistBaselineArtifacts(targetDirectory, artifacts, options.fileOperations);
|
||||||
const artifacts = buildCapturedArtifacts(metadata, rawSql);
|
|
||||||
persistBaselineArtifacts(targetDirectory, artifacts);
|
|
||||||
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
|
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,7 +961,7 @@ export function resolveFixedDevelopmentEnv(repositoryRoot) {
|
|||||||
return path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env.example');
|
return path.join(repositoryRoot, 'deploy', 'env', 'development', 'api.env.example');
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommandLine() {
|
export async function runCommandLine() {
|
||||||
const [command, ...commandArguments] = process.argv.slice(2);
|
const [command, ...commandArguments] = process.argv.slice(2);
|
||||||
if (!['generate', 'verify'].includes(command) || commandArguments.length > 0) {
|
if (!['generate', 'verify'].includes(command) || commandArguments.length > 0) {
|
||||||
throw new Error('用法:node tools/database/schema-baseline.mjs <generate|verify>(不接受额外参数)');
|
throw new Error('用法:node tools/database/schema-baseline.mjs <generate|verify>(不接受额外参数)');
|
||||||
@@ -824,7 +970,7 @@ function runCommandLine() {
|
|||||||
const release = getCurrentReleasedVersion(repositoryRoot);
|
const release = getCurrentReleasedVersion(repositoryRoot);
|
||||||
const result =
|
const result =
|
||||||
command === 'generate'
|
command === 'generate'
|
||||||
? runBaselineGeneration({
|
? await runBaselineGeneration({
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
envFile: resolveFixedDevelopmentEnv(repositoryRoot),
|
envFile: resolveFixedDevelopmentEnv(repositoryRoot),
|
||||||
...release,
|
...release,
|
||||||
@@ -837,5 +983,5 @@ function runCommandLine() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
runCommandLine();
|
await runCommandLine();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -45,6 +45,20 @@ const validMetadata = Object.freeze({
|
|||||||
generatedAt: '2026-08-13T03:00:00.000Z',
|
generatedAt: '2026-08-13T03:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const validNodeMetadata = Object.freeze({
|
||||||
|
formatVersion: 3,
|
||||||
|
applicationVersion: 'v0.0.9',
|
||||||
|
gitCommit: '88e2fdffc8c71dd682405c148a81953a8dcee9d6',
|
||||||
|
exporterName: 'mysql2-show-create',
|
||||||
|
exporterVersion: '1.0.0',
|
||||||
|
driverName: 'mysql2',
|
||||||
|
driverVersion: '3.23.3',
|
||||||
|
serverVersion: '5.7.42-log',
|
||||||
|
databaseCharset: 'utf8mb4',
|
||||||
|
databaseCollation: 'utf8mb4_general_ci',
|
||||||
|
generatedAt: '2026-08-18T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
function makeTemporaryDirectory() {
|
function makeTemporaryDirectory() {
|
||||||
return mkdtempSync(path.join(tmpdir(), 'a-share-schema-baseline-'));
|
return mkdtempSync(path.join(tmpdir(), 'a-share-schema-baseline-'));
|
||||||
}
|
}
|
||||||
@@ -132,19 +146,28 @@ test('拒绝错误字符集、排序规则和不完整的启用配置', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('只接受 MySQL 5.7 客户端、5.7.42 服务端和固定数据库字符规则', () => {
|
test('接受完整 MySQL 5.7.x 服务端系列', () => {
|
||||||
assert.doesNotThrow(() => assertMysql57Metadata(validMetadata));
|
for (const serverVersion of ['5.7.0', '5.7.41', '5.7.42-log']) {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertMysql57Metadata({ ...validMetadata, serverVersion }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('拒绝非 MySQL 5.7.x 版本并保持固定数据库字符规则', () => {
|
||||||
|
for (const serverVersion of ['5.6.51', '8.0.40', '5.7', '5.7.x', '']) {
|
||||||
|
assert.throws(
|
||||||
|
() => assertMysql57Metadata({ ...validMetadata, serverVersion }),
|
||||||
|
/MySQL 服务端必须为 5\.7\.x/,
|
||||||
|
);
|
||||||
|
}
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertMysql57Metadata({ ...validMetadata, serverVersion: '8.0.39' }),
|
() => assertMysql57Metadata({ ...validMetadata, mysqlClientVersion: '8.0.40' }),
|
||||||
/服务端必须为 5\.7\.42/,
|
/mysql 客户端必须为 5\.7\.x/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertMysql57Metadata({ ...validMetadata, serverVersion: '5.7.41' }),
|
() => assertMysql57Metadata({ ...validMetadata, mysqldumpClientVersion: '8.0.40' }),
|
||||||
/服务端必须为 5\.7\.42/,
|
/mysqldump 客户端必须为 5\.7\.x/,
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => assertMysql57Metadata({ ...validMetadata, mysqlClientVersion: '8.0.39' }),
|
|
||||||
/mysql 客户端必须为 5\.7/,
|
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertMysql57Metadata({ ...validMetadata, databaseCollation: 'utf8mb4_bin' }),
|
() => assertMysql57Metadata({ ...validMetadata, databaseCollation: 'utf8mb4_bin' }),
|
||||||
@@ -291,6 +314,29 @@ test('拒绝静默覆盖已存在且内容不同的版本基线', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('写入第三个文件失败时清理目标目录和临时目录', () => {
|
||||||
|
const root = makeTemporaryDirectory();
|
||||||
|
const target = path.join(root, 'v0.0.5');
|
||||||
|
const artifacts = buildCapturedArtifacts(
|
||||||
|
validMetadata,
|
||||||
|
'CREATE TABLE `sample` (`id` int);\n',
|
||||||
|
);
|
||||||
|
let writeCount = 0;
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
persistBaselineArtifacts(target, artifacts, {
|
||||||
|
writeFileSync(...args) {
|
||||||
|
writeCount += 1;
|
||||||
|
if (writeCount === 3) throw new Error('injected write failure');
|
||||||
|
return writeFileSync(...args);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/injected write failure/,
|
||||||
|
);
|
||||||
|
assert.equal(readdirSync(root).includes('v0.0.5'), false);
|
||||||
|
assert.equal(readdirSync(root).some((entry) => entry.includes('.tmp-')), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('MySQL 未启用时只生成可验证不适用清单', () => {
|
test('MySQL 未启用时只生成可验证不适用清单', () => {
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
const target = path.join(root, 'v0.0.5');
|
const target = path.join(root, 'v0.0.5');
|
||||||
@@ -305,7 +351,7 @@ test('MySQL 未启用时只生成可验证不适用清单', () => {
|
|||||||
assert.throws(() => readFileSync(path.join(target, 'checksum.sha256')), /ENOENT/);
|
assert.throws(() => readFileSync(path.join(target, 'checksum.sha256')), /ENOENT/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('未启用入口不调用数据库且拒绝额外命令参数', () => {
|
test('未启用入口不调用数据库且拒绝额外命令参数', async () => {
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
const envFile = path.join(root, 'development.env');
|
const envFile = path.join(root, 'development.env');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -314,7 +360,7 @@ test('未启用入口不调用数据库且拒绝额外命令参数', () => {
|
|||||||
);
|
);
|
||||||
let executionCount = 0;
|
let executionCount = 0;
|
||||||
|
|
||||||
const result = runBaselineGeneration({
|
const result = await runBaselineGeneration({
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
envFile,
|
envFile,
|
||||||
applicationVersion: 'v0.0.5',
|
applicationVersion: 'v0.0.5',
|
||||||
@@ -328,13 +374,13 @@ test('未启用入口不调用数据库且拒绝额外命令参数', () => {
|
|||||||
|
|
||||||
assert.equal(result.status, 'not_applicable');
|
assert.equal(result.status, 'not_applicable');
|
||||||
assert.equal(executionCount, 0);
|
assert.equal(executionCount, 0);
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() => runBaselineGeneration({ ...result, commandArguments: ['--env', 'production.env'] }),
|
() => runBaselineGeneration({ ...result, commandArguments: ['--env', 'production.env'] }),
|
||||||
/不接受命令参数/,
|
/不接受命令参数/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('启用入口分别校验 mysql 与 mysqldump 客户端版本', () => {
|
test('启用入口使用异步 Node exporter', async () => {
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
const envFile = path.join(root, 'development.env');
|
const envFile = path.join(root, 'development.env');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -344,31 +390,27 @@ test('启用入口分别校验 mysql 与 mysqldump 客户端版本', () => {
|
|||||||
.join('\n'),
|
.join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
const result = await runBaselineGeneration({
|
||||||
() =>
|
|
||||||
runBaselineGeneration({
|
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
envFile,
|
envFile,
|
||||||
applicationVersion: 'v0.0.5',
|
applicationVersion: 'v0.0.5',
|
||||||
gitCommit: validMetadata.gitCommit,
|
gitCommit: validMetadata.gitCommit,
|
||||||
generatedAt: validMetadata.generatedAt,
|
generatedAt: validMetadata.generatedAt,
|
||||||
executeFile(command, args) {
|
exportSchema: async () => ({
|
||||||
if (args[0] === '--version') {
|
metadata: {
|
||||||
return command === 'mysql'
|
...validNodeMetadata,
|
||||||
? 'mysql Ver 8.0.39 for macos'
|
applicationVersion: undefined,
|
||||||
: 'mysqldump Ver 10.13 Distrib 5.7.44, for macos';
|
gitCommit: undefined,
|
||||||
}
|
generatedAt: undefined,
|
||||||
if (command === 'mysql') {
|
},
|
||||||
return '5.7.42-log\tutf8mb4\tutf8mb4_general_ci\n';
|
rawSql: '',
|
||||||
}
|
objectCounts: { tables: 0, views: 0, triggers: 0, routines: 0, events: 0 },
|
||||||
return 'CREATE TABLE `sample` (`id` int);\n';
|
}),
|
||||||
},
|
});
|
||||||
}),
|
assert.equal(result.status, 'captured_empty');
|
||||||
/mysql 客户端必须为 5\.7/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('幂等重跑仍核对清单提交与发布标签提交', () => {
|
test('幂等重跑仍核对清单提交与发布标签提交', async () => {
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
const envFile = path.join(root, 'development.env');
|
const envFile = path.join(root, 'development.env');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -378,9 +420,8 @@ test('幂等重跑仍核对清单提交与发布标签提交', () => {
|
|||||||
const target = path.join(root, 'database', 'schema-baselines', 'v0.0.5');
|
const target = path.join(root, 'database', 'schema-baselines', 'v0.0.5');
|
||||||
persistBaselineArtifacts(target, buildNotApplicableArtifacts(validMetadata));
|
persistBaselineArtifacts(target, buildNotApplicableArtifacts(validMetadata));
|
||||||
|
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() =>
|
() => runBaselineGeneration({
|
||||||
runBaselineGeneration({
|
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
envFile,
|
envFile,
|
||||||
applicationVersion: 'v0.0.5',
|
applicationVersion: 'v0.0.5',
|
||||||
@@ -442,7 +483,7 @@ test('仅含 mysqldump 会话设置的真实空库仍生成 captured_empty', ()
|
|||||||
assert.deepEqual(Object.keys(artifacts.files), ['manifest.yaml']);
|
assert.deepEqual(Object.keys(artifacts.files), ['manifest.yaml']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => {
|
test('启用入口把 Node exporter 空结构持久化为 captured_empty', async () => {
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
const envFile = path.join(root, 'development.env');
|
const envFile = path.join(root, 'development.env');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@@ -452,21 +493,17 @@ test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => {
|
|||||||
.join('\n'),
|
.join('\n'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = runBaselineGeneration({
|
const result = await runBaselineGeneration({
|
||||||
repositoryRoot: root,
|
repositoryRoot: root,
|
||||||
envFile,
|
envFile,
|
||||||
applicationVersion: 'v0.0.8',
|
applicationVersion: 'v0.0.8',
|
||||||
gitCommit: validMetadata.gitCommit,
|
gitCommit: validMetadata.gitCommit,
|
||||||
generatedAt: validMetadata.generatedAt,
|
generatedAt: validMetadata.generatedAt,
|
||||||
executeFile(command, args) {
|
exportSchema: async () => ({
|
||||||
if (args[0] === '--version') {
|
metadata: { ...validNodeMetadata, applicationVersion: 'v0.0.8' },
|
||||||
return `${command} Ver 14.14 Distrib 5.7.44, for Linux`;
|
rawSql: '',
|
||||||
}
|
objectCounts: { tables: 0, views: 0, triggers: 0, routines: 0, events: 0 },
|
||||||
if (command === 'mysql') {
|
}),
|
||||||
return '5.7.42-log\tutf8mb4\tutf8mb4_general_ci\n';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.status, 'captured_empty');
|
assert.equal(result.status, 'captured_empty');
|
||||||
@@ -479,6 +516,79 @@ test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('format v3 清单使用 Node exporter/driver 字段且不复用 CLI 字段', () => {
|
||||||
|
const artifacts = buildCapturedArtifacts(
|
||||||
|
validNodeMetadata,
|
||||||
|
'CREATE TABLE `sample` (`id` int);\n',
|
||||||
|
{ tables: 1, views: 0, triggers: 0, routines: 0, events: 0 },
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(artifacts.files['manifest.yaml']);
|
||||||
|
|
||||||
|
assert.equal(manifest.format_version, 3);
|
||||||
|
assert.equal(manifest.generator, 'schema-baseline/3.0.0');
|
||||||
|
assert.equal(manifest.exporter_name, 'mysql2-show-create');
|
||||||
|
assert.equal(manifest.exporter_version, '1.0.0');
|
||||||
|
assert.equal(manifest.database_driver, 'mysql2');
|
||||||
|
assert.equal(manifest.database_driver_version, '3.23.3');
|
||||||
|
assert.equal('mysql_client_version' in manifest, false);
|
||||||
|
assert.equal('mysqldump_client_version' in manifest, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('format v3 空库和不适用清单保留可验证状态', () => {
|
||||||
|
const emptyManifest = JSON.parse(
|
||||||
|
buildCapturedEmptyArtifacts(validNodeMetadata).files['manifest.yaml'],
|
||||||
|
);
|
||||||
|
assert.equal(emptyManifest.format_version, 3);
|
||||||
|
assert.equal(emptyManifest.status, 'captured_empty');
|
||||||
|
assert.equal(emptyManifest.object_counts.tables, 0);
|
||||||
|
|
||||||
|
const disabledManifest = JSON.parse(
|
||||||
|
buildNotApplicableArtifacts(validNodeMetadata).files['manifest.yaml'],
|
||||||
|
);
|
||||||
|
assert.equal(disabledManifest.format_version, 3);
|
||||||
|
assert.equal(disabledManifest.status, 'not_applicable');
|
||||||
|
assert.equal(disabledManifest.database_driver, 'mysql2');
|
||||||
|
assert.equal(disabledManifest.mysql_server_version, 'not_enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步 Node exporter 注入路径生成 format v3 captured_empty', async () => {
|
||||||
|
const root = makeTemporaryDirectory();
|
||||||
|
const envFile = path.join(root, 'development.env');
|
||||||
|
writeFileSync(
|
||||||
|
envFile,
|
||||||
|
Object.entries(validConfig)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('\n'),
|
||||||
|
);
|
||||||
|
let exportCalls = 0;
|
||||||
|
const result = await runBaselineGeneration({
|
||||||
|
repositoryRoot: root,
|
||||||
|
envFile,
|
||||||
|
applicationVersion: 'v0.0.9',
|
||||||
|
gitCommit: validNodeMetadata.gitCommit,
|
||||||
|
generatedAt: validNodeMetadata.generatedAt,
|
||||||
|
exportSchema: async () => {
|
||||||
|
exportCalls += 1;
|
||||||
|
return {
|
||||||
|
metadata: validNodeMetadata,
|
||||||
|
rawSql: '',
|
||||||
|
objectCounts: { tables: 0, views: 0, triggers: 0, routines: 0, events: 0 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'captured_empty');
|
||||||
|
assert.equal(exportCalls, 1);
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
path.join(root, 'database', 'schema-baselines', 'v0.0.9', 'manifest.yaml'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
).format_version,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('基线清单中的提交必须等于发布标签指向的提交', () => {
|
test('基线清单中的提交必须等于发布标签指向的提交', () => {
|
||||||
const root = makeTemporaryDirectory();
|
const root = makeTemporaryDirectory();
|
||||||
const target = path.join(root, 'v0.0.5');
|
const target = path.join(root, 'v0.0.5');
|
||||||
@@ -501,7 +611,7 @@ test('校验拒绝篡改成错误 MySQL 版本或伪造对象范围的真实清
|
|||||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||||
manifest.mysql_server_version = '8.0.39';
|
manifest.mysql_server_version = '8.0.39';
|
||||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7\.42/);
|
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7\.x/);
|
||||||
|
|
||||||
manifest.mysql_server_version = '5.7.42-log';
|
manifest.mysql_server_version = '5.7.42-log';
|
||||||
manifest.objects.events = false;
|
manifest.objects.events = false;
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ test('部署文档要求本机外部 Redis 和唯一 Ubuntu 源码生产路径',
|
|||||||
assert.doesNotMatch(source, /systemctl (?:restart|status) a-share-\w+\.service\.example/);
|
assert.doesNotMatch(source, /systemctl (?:restart|status) a-share-\w+\.service\.example/);
|
||||||
assert.match(source, /不负责安装/);
|
assert.match(source, /不负责安装/);
|
||||||
for (const document of [local, source]) {
|
for (const document of [local, source]) {
|
||||||
assert.match(document, /MySQL 5\.7\.42/);
|
assert.match(document, /MySQL 5\.7\.x/);
|
||||||
|
assert.doesNotMatch(document, /MySQL 5\.7\.42/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import test from 'node:test';
|
|||||||
const architecturePath = new URL('../../docs/architecture/system-architecture.md', import.meta.url);
|
const architecturePath = new URL('../../docs/architecture/system-architecture.md', import.meta.url);
|
||||||
const standardsPath = new URL('../../docs/governance/code-development-standards.md', import.meta.url);
|
const standardsPath = new URL('../../docs/governance/code-development-standards.md', import.meta.url);
|
||||||
|
|
||||||
test('系统架构固定 MySQL 5.7 字符集与排序规则', async () => {
|
test('系统架构固定 MySQL 5.7.x 系列、字符集与排序规则', async () => {
|
||||||
const architecture = await readFile(architecturePath, 'utf8');
|
const architecture = await readFile(architecturePath, 'utf8');
|
||||||
|
|
||||||
assert.match(architecture, /MySQL 5\.7/);
|
assert.match(architecture, /MySQL 5\.7\.x/);
|
||||||
|
assert.doesNotMatch(architecture, /MySQL 5\.7\.42/);
|
||||||
assert.match(architecture, /utf8mb4/);
|
assert.match(architecture, /utf8mb4/);
|
||||||
assert.match(architecture, /utf8mb4_general_ci/);
|
assert.match(architecture, /utf8mb4_general_ci/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('开发规范覆盖 MySQL 5.7 全链路兼容性要求', async () => {
|
test('开发规范覆盖 MySQL 5.7.x 全链路兼容性要求', async () => {
|
||||||
const standards = await readFile(standardsPath, 'utf8');
|
const standards = await readFile(standardsPath, 'utf8');
|
||||||
|
|
||||||
for (const requiredText of [
|
for (const requiredText of [
|
||||||
'MySQL 5.7',
|
'MySQL 5.7.x',
|
||||||
'utf8mb4',
|
'utf8mb4',
|
||||||
'utf8mb4_general_ci',
|
'utf8mb4_general_ci',
|
||||||
'公用表表达式',
|
'公用表表达式',
|
||||||
@@ -31,4 +32,5 @@ test('开发规范覆盖 MySQL 5.7 全链路兼容性要求', async () => {
|
|||||||
]) {
|
]) {
|
||||||
assert.ok(standards.includes(requiredText), `开发规范缺少数据库基线:${requiredText}`);
|
assert.ok(standards.includes(requiredText), `开发规范缺少数据库基线:${requiredText}`);
|
||||||
}
|
}
|
||||||
|
assert.doesNotMatch(standards, /MySQL 5\.7\.42/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { assertSupportedRuntime } from './verify-runtime.mjs';
|
import { assertSupportedRuntime } from './verify-runtime.mjs';
|
||||||
|
|
||||||
test('只接受 Node 22 与 pnpm 10', () => {
|
test('接受完整 Node.js 22.x 并拒绝相邻主版本', () => {
|
||||||
assert.doesNotThrow(() =>
|
for (const nodeVersion of ['22.0.0', '22.11.0', '22.99.123-beta.1+build.7']) {
|
||||||
assertSupportedRuntime({ nodeVersion: '22.11.0', pnpmVersion: '10.25.0' }),
|
assert.doesNotThrow(() => assertSupportedRuntime({ nodeVersion, pnpmVersion: '10.25.0' }));
|
||||||
);
|
}
|
||||||
|
|
||||||
for (const nodeVersion of ['20.20.2', '24.0.0']) {
|
for (const nodeVersion of ['21.7.3', '23.0.0']) {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertSupportedRuntime({ nodeVersion, pnpmVersion: '10.25.0' }),
|
() => assertSupportedRuntime({ nodeVersion, pnpmVersion: '10.25.0' }),
|
||||||
new RegExp(`Node\\.js 必须为 22\\.x,实际 ${nodeVersion.replaceAll('.', '\\.')}`),
|
new RegExp(`Node\\.js 必须为 22\\.x,实际 ${nodeVersion.replaceAll('.', '\\.')}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只接受 pnpm 10.x', () => {
|
||||||
for (const pnpmVersion of ['9.15.0', '11.0.0']) {
|
for (const pnpmVersion of ['9.15.0', '11.0.0']) {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertSupportedRuntime({ nodeVersion: '22.11.0', pnpmVersion }),
|
() => assertSupportedRuntime({ nodeVersion: '22.11.0', pnpmVersion }),
|
||||||
|
|||||||
Reference in New Issue
Block a user