Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f6ed00e6f | ||
|
|
c2fdfa625b | ||
|
|
ae2a7e08ed | ||
|
|
7e4dcd9232 | ||
|
|
49f6be9c43 | ||
|
|
ff8ada8316 | ||
|
|
82619489a9 | ||
|
|
27bc3c198f | ||
|
|
15a25002ce | ||
|
|
c2fa929936 | ||
|
|
6eb34b5afe | ||
|
|
d283ee3196 | ||
|
|
9a965fab14 | ||
|
|
b49e7b8b08 | ||
|
|
11edeb0111 | ||
|
|
16bde94df6 | ||
|
|
58026aead3 | ||
|
|
f1c412b6a3 | ||
|
|
afd186c8a4 | ||
|
|
874796ab65 | ||
|
|
3d12c83621 | ||
|
|
c11de09fa7 | ||
|
|
dc8ad7d4b6 | ||
|
|
7e9d9065bb | ||
|
|
54fd92837a | ||
|
|
8e954a3f5f | ||
|
|
06466f388e |
@@ -29,7 +29,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **a-share-analysis** (2186 symbols, 2864 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** (2432 symbols, 3283 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,13 @@
|
|||||||
|
|
||||||
## 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
|
## 0.0.9 - 2026-08-17
|
||||||
|
|
||||||
- Node.js 运行时约束明确为完整 22.x,不绑定 minor 或 patch。
|
- Node.js 运行时约束明确为完整 22.x,不绑定 minor 或 patch。
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
+2
@@ -4,6 +4,8 @@ APP_ENV=development
|
|||||||
API_HOST=127.0.0.1
|
API_HOST=127.0.0.1
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
TDX_BASE_URL=http://127.0.0.1:17710
|
||||||
|
TDX_TIMEOUT_MS=5000
|
||||||
|
|
||||||
# 第一阶段实际启用的 Redis。
|
# 第一阶段实际启用的 Redis。
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
|
|||||||
+2
@@ -3,6 +3,8 @@ APP_ENV=production
|
|||||||
API_HOST=127.0.0.1
|
API_HOST=127.0.0.1
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
TDX_BASE_URL=http://127.0.0.1:17710
|
||||||
|
TDX_TIMEOUT_MS=5000
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# 设计
|
# 设计
|
||||||
|
|
||||||
首屏顺序为:市场结论、驱动与风险、核心指数、市场宽度与量能、板块概览、情绪与资金摘要。分析偏好保存在浏览器本地,页面只显示轻量的当前状态入口。
|
首屏顺序为:市场结论、驱动与风险、核心指数、市场宽度与量能、板块概览、资金趋势。分析偏好保存在浏览器本地,页面显示轻量的当前状态入口。Vue 不直连 TDX,由 Nest API 按模块拆分接口并隔离失败。
|
||||||
|
|
||||||
|
## 数据可用性
|
||||||
|
|
||||||
|
- `summary` 和 `indices`:使用 TDX K 线真实响应,展示来源、快照时间和指数涨跌。
|
||||||
|
- `breadth`:使用 TDX 指数快照的 `UpHome`、`DownHome` 汇总上证、深证和北证市场宽度;未返回的 `unchanged` 明确为 `null`,不得用总数推算。
|
||||||
|
- `sectors`:使用 TDX 系统分类中的一级行业(`get_stock_list(market=16)`)和批量日行情,按涨跌幅排序后展示重点强弱板块。
|
||||||
|
- `funds`:模块名称和口径为“成交额趋势”,使用核心交易所指数日行情的 `Amount` 汇总当前与前一交易日成交额;这不是主力净流入。
|
||||||
|
- 交易日对齐:交易日期必须是有效 `YYYYMMDD` 公历日期;指数和一级行业只有在前一根日期有效且严格早于当前日期时才计算涨跌,随后只保留最新交易日截面。成交额还要求日期与金额数组等长且前一交易日早于当前交易日,再按最新有效日期对汇总,避免畸形日期、数组错位或旧市场覆盖新数据。市场宽度额外并行读取一个核心指数日线作为日期参考。
|
||||||
|
- 新鲜度:日行情交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;宽度沿用参考指数日期。首版不引入交易日历依赖,休市日保守标延迟并展示实际日期。
|
||||||
|
- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。
|
||||||
|
- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。
|
||||||
|
|
||||||
|
## 运行边界
|
||||||
|
|
||||||
|
本功能只读调用 TDX。MySQL、Redis、RocketMQ 仍为已规划基础设施,但当前没有业务状态、缓存或异步任务需要它们,因此本切片不启用。
|
||||||
|
|
||||||
|
## 安全策略与评估
|
||||||
|
|
||||||
|
- TDX 地址在配置层固定为 `http://127.0.0.1:17710`,API 是唯一访问边界,Vue 不直连行情服务。
|
||||||
|
- 请求只允许固定方法和公开行情参数;客户端限制超时与 1 MiB 响应,异常脱敏,不记录原始载荷、凭据或网络地址。
|
||||||
|
- 本功能不提供交易写入、账户操作、用户输入拼接查询或 AI 密钥下发;MySQL/Redis/RocketMQ 未启用,不读取生产数据库。
|
||||||
|
- 已通过客户端、服务、HTTP/OpenAPI、边界和 E2E 测试;生产授权、长期断线恢复和公网安全仍需后续评估。
|
||||||
|
|
||||||
|
## 性能目标与评估
|
||||||
|
|
||||||
|
- 五个模块并行请求,单个 TDX 请求受配置超时保护,模块失败不阻断其他模块。
|
||||||
|
- 客户端限制响应大小,避免异常行情载荷占用过多内存;本切片不增加数据库、缓存或消息队列负载。
|
||||||
|
- 已完成构建、浏览器桌面/390px 移动端验证;未进行 7×24 采集、峰值并发和长期网络抖动压测。
|
||||||
|
|
||||||
ETF监控、两融数据、大盘云图和实时消息仍是独立一级菜单,不属于短线和中长线切换。
|
ETF监控、两融数据、大盘云图和实时消息仍是独立一级菜单,不属于短线和中长线切换。
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# FEAT-MO-001 开发总结
|
||||||
|
|
||||||
|
## 原始开发需求
|
||||||
|
|
||||||
|
实现市场概览首屏:结论优先展示市场状态、驱动与风险,并支持综合、短线、中长线视角和规则、AI、混合、对照分析选择;用户可从指数和板块入口进入后续详细分析。
|
||||||
|
|
||||||
|
## 需求与范围变化
|
||||||
|
|
||||||
|
- 数据源从早期“确定性模拟快照”调整为 TDX 只读直连,Nest API 访问本机 `127.0.0.1:17710`,Vue 不直接访问 TDX。
|
||||||
|
- API 按摘要、指数、市场宽度、板块和资金拆分,避免把整个首屏塞入单个接口。
|
||||||
|
- 当前 TDX 桥接已确认 `get_market_snapshot`、`get_stock_list` 和批量 `get_market_data` 可用;市场宽度、一级行业板块和成交额趋势改为真实 TDX 映射。主力净流入字段虽存在但当前实测为零值,盘中新闻无官方行情接口,因此两者延期到补充源功能。
|
||||||
|
- MySQL、Redis、RocketMQ 和 AI 仍不进入本切片运行链路;它们保留为后续基础设施边界。
|
||||||
|
|
||||||
|
## 实际完成需求
|
||||||
|
|
||||||
|
- 新增 TDX 配置校验、超时/响应大小限制、错误脱敏的 Node 客户端。
|
||||||
|
- 新增市场概览 DTO、服务、控制器、OpenAPI 契约和前端 Axios API 文件。
|
||||||
|
- 完成结论、驱动与风险、核心指数、市场宽度、板块、资金趋势模块,以及加载/错误/不可用状态。
|
||||||
|
- 完成分析视角和分析引擎切换,本地保存用户偏好。
|
||||||
|
- 真实 TDX 指数和摘要已通过本机隧道冒烟;未用指数或离线样本伪造缺失指标。
|
||||||
|
- 真实 TDX 市场宽度、一级行业和成交额趋势已通过本机桥接冒烟;资金模块明确使用成交额口径,不宣称主力净流入。
|
||||||
|
|
||||||
|
## 修复缺陷
|
||||||
|
|
||||||
|
- 修正 PRD 和实施计划中仍描述“模拟数据”的旧表述。
|
||||||
|
- 避免单个 TDX 模块失败导致整页失败,改为五个模块并行、独立状态。
|
||||||
|
- 对未注册桥接方法返回结构化原因,避免前端显示“实时”或空白数据。
|
||||||
|
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
|
||||||
|
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
|
||||||
|
- 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。
|
||||||
|
- 根据二次 Review 继续修复涨跌家数接受非整数、成交额受市场定义顺序影响、指数/板块跨交易日混排和后端从不生成 `delayed` 的边界;宽度增加核心指数日期参考,不新增基础设施或第三方依赖。
|
||||||
|
- 根据第三轮独立 Review 修复 8 位畸形日期被误认为有效公历日期、成交额接受倒序日期对与日期/金额数组错位,以及服务测试依赖运行当天的问题。
|
||||||
|
- 根据第四轮独立 Review 修复指数/板块未校验前一根 K 线日期的问题;非法、重复或倒序的前一日期不再生成涨跌信号。
|
||||||
|
|
||||||
|
## 未完成与延期项
|
||||||
|
|
||||||
|
- 主力净流入的权限/数据质量验证、盘中新闻补充源、真实图表和口径仍延期。
|
||||||
|
- “查看依据”抽屉、指数/板块详情页、AI 请求和用户体系另立功能。
|
||||||
|
- 本地验证不代表生产 7×24 采集稳定性、商业授权或公网部署安全性。
|
||||||
|
|
||||||
|
## 关键提交
|
||||||
|
|
||||||
|
- `9a965fa`、`d283ee3`、`6eb34b5`、`c2fa929`、`15a2500`、`27bc3c1`、`8261948`、`ff8ada8`、`49f6be9`、`7e4dcd9`、`ae2a7e0`、`c2fdfa6`
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- GitNexus 已完成提交前重新索引;上一修复提交的最终 staged 检测映射 66 个符号、21 条概览执行流并标记 critical。风险来自本功能修改核心概览服务、TDX 映射和前端数据面板,已由定向单测、HTTP/OpenAPI、前端状态回归、构建、浏览器视觉验证和 E2E 覆盖;未发现额外业务模块调用方。第三轮修复前又用 MCP 对 `toTradeDate` 和 `mapTurnoverTrend` 执行符号级影响分析,直接调用方仅在市场概览映射与测试范围。
|
||||||
|
- API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。
|
||||||
|
- 家庭 LAN 隧道二次复验:五个市场概览接口均为 `fresh`,指数 3 项、板块 8 项、成交额覆盖 3 个市场且交易日期完整;只保存脱敏统计。
|
||||||
|
- 日期对齐与延迟状态修复后,后端 60/60、完整全仓门禁和家庭 LAN 五接口当日 `fresh` 复验通过;第三轮日期真实性与测试时钟修复后,后端增加至 63/63;第四轮相邻日期修复后,后端增加至 67/67。`c2fdfa6` 推送前后均执行完整 `pnpm verify`,契约、Lint、格式、类型、全部测试、构建和 E2E 均通过。
|
||||||
|
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
|
||||||
|
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
|
||||||
|
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
|
||||||
|
- 五轮独立 Review 均已归档:前四轮发现的数据完整性、状态、日期真实性/顺序、测试时钟和前一根日期校验问题均已关闭;第五轮最终结论为 `Ready to merge: Yes`,无 Critical、Important 或 Minor 问题。
|
||||||
|
- 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# GitNexus 开发后基线
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis/.worktrees/feat-mo-001-tdx-overview
|
||||||
|
Indexed: 8/18/2026, 9:13:11 PM
|
||||||
|
Indexed commit: c2fdfa6
|
||||||
|
Current commit: c2fdfa6
|
||||||
|
Status: ✅ up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 已跟踪变更摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
AGENTS.md | 2 +-
|
||||||
|
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 工作区摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
M AGENTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
业务代码基线 `c2fdfa6` 已提交并推送;上述唯一工作区差异是 GitNexus 自动维护的入口说明,未纳入功能提交。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# GitNexus 前后对比
|
||||||
|
|
||||||
|
## 开发前
|
||||||
|
|
||||||
|
- 基线:`gitnexus/before.md`、`gitnexus/implementation-baseline.md`。
|
||||||
|
- 目标是把模拟首屏改为 TDX 只读概览,并拆分五个 HTTP 模块接口。
|
||||||
|
|
||||||
|
## 开发后
|
||||||
|
|
||||||
|
- CLI 已完成最终重新索引,保留 85 条执行流并识别本功能影响路径。
|
||||||
|
- 宽范围修复曾识别 21 条概览执行流并标记 critical;最终相邻日期修复收敛为 3 条候选映射执行流并标记 medium。核心路径均有单测、HTTP/OpenAPI、前端状态、构建、视觉和 E2E 验证。
|
||||||
|
- 未新增消息、数据库、Redis、Scheduler、Worker 或部署运行时依赖。
|
||||||
|
|
||||||
|
## 差异结论
|
||||||
|
|
||||||
|
直接影响集中于 TDX API、市场概览服务、前端 API/页面和测试夹具;生产数据边界、基础设施暂不启用边界和回退路径保持不变。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# GitNexus 开发后变更检测
|
||||||
|
|
||||||
|
## 检测命令
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx gitnexus analyze
|
||||||
|
npx gitnexus detect-changes --repo "$PWD" --scope staged
|
||||||
|
```
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 索引:已完成最终增量更新。
|
||||||
|
- 暂存文件:11 个文件、24 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。
|
||||||
|
- 影响执行流:3 条,CLI 风险:medium。
|
||||||
|
- 主要执行流:`Candidates → ToNumber`、`Candidates → ToTradeDate` 与 `Candidates → MapDirection`;本轮未修改服务状态策略或跨模块契约。
|
||||||
|
|
||||||
|
## 处置
|
||||||
|
|
||||||
|
本轮 medium 来自指数/板块相邻日期比较的局部映射变更,不是未知跨业务扩散。已用 TDX 映射/服务测试、类型检查和 `git diff --check` 验证;前一提交已覆盖 HTTP/OpenAPI、前端状态、构建、浏览器视觉和 4/4 E2E,未发现用户、消息、数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境已使用 GitNexus MCP 与同版本 CLI 交叉确认,没有将工具限制误判为低风险。
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
- 功能编号:FEAT-MO-001
|
- 功能编号:FEAT-MO-001
|
||||||
- 基线提交:`c0a6f5560584`
|
- 基线提交:`c0a6f5560584`
|
||||||
- 当前阶段:TDX 开发连接设计与首页方案确认
|
- 当前阶段:TDX 直连首页实施,已完成开发前门禁和最小只读协议验证
|
||||||
- 当前总体风险:LOW(仅本机 SSH 配置和操作文档);首页业务代码风险待设计确认后按符号补充
|
- 当前总体风险:LOW(新增边界尚未落地);TDX 客户端、API、OpenAPI 和 Web 首屏实现后按符号补充
|
||||||
|
|
||||||
## 直接与间接影响
|
## 直接与间接影响
|
||||||
|
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
|
|
||||||
- 正式行情来源确定为 TDX;家庭局域网和外部 Tailscale 只改变 Mac 到同一 Windows `sshd` 的网络路径。
|
- 正式行情来源确定为 TDX;家庭局域网和外部 Tailscale 只改变 Mac 到同一 Windows `sshd` 的网络路径。
|
||||||
- TDX Bridge 的稳定入口固定为 Mac 回环地址 `127.0.0.1:17710`,市场概览、规则分析、AI 分析和未来 HTTP 契约不得依赖家庭地址、Tailscale 地址或 TDX 原始字段。
|
- TDX Bridge 的稳定入口固定为 Mac 回环地址 `127.0.0.1:17710`,市场概览、规则分析、AI 分析和未来 HTTP 契约不得依赖家庭地址、Tailscale 地址或 TDX 原始字段。
|
||||||
- 当前仓库没有 TDX Provider 或市场概览运行时符号,本阶段只更新操作文档;GitNexus 未识别可执行调用链影响。
|
- 当前仓库尚无 TDX Provider 或市场概览运行时符号;本次将新增 API 业务模块、TDX 客户端、OpenAPI 契约和 Web 首屏。新增符号的直接调用链将在实现后重新索引并执行变更检测。
|
||||||
- 首页业务设计确认后,必须对 `App.vue`、`createAppRouter`、后端模块装配、HTTP DTO 和新增市场概览符号分别执行 upstream impact。HIGH 或 CRITICAL 结果先告知用户再修改。
|
- 首页业务设计确认后,必须对 `App.vue`、`createAppRouter`、后端模块装配、HTTP DTO 和新增市场概览符号分别执行 upstream impact。HIGH 或 CRITICAL 结果先告知用户再修改。
|
||||||
|
|
||||||
## 跨模块同步修改
|
## 跨模块同步修改
|
||||||
|
|
||||||
- HTTP/OpenAPI:当前连接文档不修改;首页正式接入时需要版本化市场概览 DTO 与 OpenAPI 契约。
|
- HTTP/OpenAPI:本次新增版本化市场概览 DTO 和五个只读路径,并重新生成 OpenAPI 契约。
|
||||||
- Web API 与 adapter:当前不修改;未来页面只能经 `web/src/api/<业务域>/index.ts`、Axios 封装和 ViewModel 消费数据。
|
- Web API 与 adapter:本次新增 `web/src/api/market-overview/index.ts`,页面只能经 Axios 封装和 ViewModel 消费数据。
|
||||||
- 消息契约、Redis、MySQL、Scheduler、Worker:当前不启用。正式采集链路仍按独立功能立项,不能由 API 请求临时抓取全市场数据。
|
- 消息契约、Redis、MySQL、Scheduler、Worker:本次首屏不启用业务链路,仅保留既有架构边界;正式采集、快照持久化、缓存和异步分发仍按独立功能立项。
|
||||||
- 配置部署:真实地址、用户名、密钥和控制套接字只保留在开发机,不进入仓库、环境模板或前端构建物。
|
- 配置部署:真实地址、用户名、密钥和控制套接字只保留在开发机,不进入仓库、环境模板或前端构建物。
|
||||||
- 测试:两种 SSH 路径均需验证到同一 Mac 回环端点;离线样本只作显式开发回退,不得标记为实时 TDX。
|
- 测试:两种 SSH 路径均需验证到同一 Mac 回环端点;离线样本只作显式开发回退,不得标记为实时 TDX。
|
||||||
- 文档:同步更新 Tailscale/SSH 开发手册,明确家庭局域网与外部 Tailscale 的选择、启动、停止、切换和验证方法。
|
- 文档:同步更新 Tailscale/SSH 开发手册,明确家庭局域网与外部 Tailscale 的选择、启动、停止、切换和验证方法。
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# FEAT-MO-001 实施前门禁基线
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-001
|
||||||
|
- 分支:`codex/feat-mo-001-tdx-overview`
|
||||||
|
- 基线提交:`d283ee319692d4550149c591d4fe7f522c0bc6b8`
|
||||||
|
- 记录时间:2026-08-18 13:04 CST
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
- `pnpm schema:baseline`:通过,`v0.0.10 captured_empty`。
|
||||||
|
- `pnpm schema:verify`:通过,`v0.0.10 captured_empty`。
|
||||||
|
- 本次没有数据库结构变更,不读取生产数据库。
|
||||||
|
|
||||||
|
## GitNexus
|
||||||
|
|
||||||
|
```text
|
||||||
|
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis/.worktrees/feat-mo-001-tdx-overview
|
||||||
|
Indexed commit: d283ee3
|
||||||
|
Current commit: d283ee3
|
||||||
|
Status: up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
影响分析结果:
|
||||||
|
|
||||||
|
- `ApiModule` upstream:LOW,直接消费者为 API `main.ts` 和进程冒烟测试。
|
||||||
|
- `createAppRouter` upstream:LOW,无直接执行流消费者。
|
||||||
|
- `createRequestClient` upstream:LOW,直接使用方为 Web API 健康请求模块。
|
||||||
|
- `ApiModule.register` 复合名称未被 CLI 解析,已用 `ApiModule` 类上下文、`main.ts` 静态引用和 `register` 候选结果补齐证据;没有 HIGH/CRITICAL 结果。
|
||||||
|
|
||||||
|
## 工作区说明
|
||||||
|
|
||||||
|
主工作区已有用户修改的 `AGENTS.md`,本功能分支不将其纳入提交。功能代码只在本隔离工作区开发。
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
# 实施计划
|
# 实施计划
|
||||||
|
|
||||||
1. 初始化 Vue 3、Vite、TypeScript和 Dart Sass。
|
1. ~~初始化 Vue 3、Vite、TypeScript 和 Dart Sass。~~ 已完成。
|
||||||
2. 建立市场快照类型和确定性模拟数据。
|
2. ~~建立 TDX 配置、Node fetch 客户端、DTO、映射和五个模块接口。~~ 已完成。
|
||||||
3. 实现终端外壳、数据状态和结论区。
|
3. ~~实现终端外壳、分模块加载状态和结论区。~~ 已完成。
|
||||||
4. 实现指数、宽度、量能、板块、情绪与资金模块。
|
4. ~~实现指数、宽度、板块和资金模块;不可用指标使用结构化状态。~~ 已完成。
|
||||||
5. 实现分析偏好菜单和依据抽屉。
|
5. ~~实现分析偏好菜单和本地持久化。~~ 已完成;依据抽屉和详情页入口延期至独立功能。
|
||||||
6. 完成测试、构建、浏览器验证和视觉对比。
|
6. ~~完成单元测试、类型检查、构建、接口冒烟、浏览器桌面/移动端视觉验证。~~ 已完成。
|
||||||
|
7. ~~补齐 TDX 市场宽度、一级行业板块和成交额趋势映射。~~ 已确认官方接口和本机桥接可用;主力净流入与盘中新闻保留为补充源需求。
|
||||||
|
8. 更新 DTO、OpenAPI 类型和前端组件,使真实数据状态、单位和 `unchanged=null` 口径可见。
|
||||||
|
9. 完成 TDX 真实响应夹具、错误隔离、类型、构建和浏览器视觉验证;Review 后再收口功能档案。
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
id: FEAT-MO-001
|
id: FEAT-MO-001
|
||||||
name: 市场综合概览
|
name: 市场综合概览
|
||||||
slug: market-overview
|
slug: market-overview
|
||||||
status: approved
|
status: in_review
|
||||||
owner: project
|
owner: project
|
||||||
created_at: 2026-08-12
|
created_at: 2026-08-12
|
||||||
requirements: requirements.md
|
requirements: requirements.md
|
||||||
prd: prd.md
|
prd: prd.md
|
||||||
design: design.md
|
design: design.md
|
||||||
implementation_plan: implementation-plan.md
|
implementation_plan: implementation-plan.md
|
||||||
traceability_note: 开发尚未开始,GitNexus与QA证据将在开发门禁执行时创建
|
traceability_note: 已完成 TDX 直连开发、GitNexus 前后分析、接口冒烟、桌面/移动端视觉验证、独立代码 Review 和规范 Review;开发完成门禁已通过,待合并发布
|
||||||
release: unreleased
|
release: unreleased
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
## 非目标
|
## 非目标
|
||||||
|
|
||||||
- 不做自选股、持仓、笔记、交易计划、社区、荐股、跟单和公开组合。
|
- 不做自选股、持仓、笔记、交易计划、社区、荐股、跟单和公开组合。
|
||||||
- 本切片不接正式行情源、RocketMQ、MySQL、Redis和付费 AI。
|
- 本切片直接接入已验证的 TDX 只读桥接层;不接入 RocketMQ、MySQL、Redis 和付费 AI。
|
||||||
- 本切片不实现用户、邀请、会员、订阅和支付。
|
- 本切片不实现用户、邀请、会员、订阅和支付。
|
||||||
- ETF监控、两融数据、大盘云图和实时消息仍是独立一级模块。
|
- ETF监控、两融数据、大盘云图和实时消息仍是独立一级模块。
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
| 编号 | 优先级 | 需求 | 可观察结果 |
|
| 编号 | 优先级 | 需求 | 可观察结果 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| FR-001 | P0 | 结论优先展示市场定性 | 首屏顶部出现一句结论、市场状态、量能状态和情绪状态 |
|
| FR-001 | P0 | 结论优先展示市场定性 | 首屏顶部出现一句结论、市场状态、量能状态和情绪状态 |
|
||||||
| FR-002 | P0 | 展示驱动与风险 | 结论下方并列显示至少三条驱动和三条风险证据 |
|
| FR-002 | P0 | 展示驱动与风险 | 结论下方并列显示已由 TDX 支持的驱动和风险证据;缺少口径时明确标记 |
|
||||||
| FR-003 | P0 | 展示数据新鲜度 | 页面显示交易状态、数据更新时间和正常/延迟/不完整状态 |
|
| FR-003 | P0 | 展示数据新鲜度 | 页面显示交易状态、数据更新时间和正常/延迟/不完整状态 |
|
||||||
| FR-004 | P0 | 提供分析偏好 | 可选择综合/短线/中长线和规则/AI/混合/对照,并在本地保存默认值 |
|
| FR-004 | P0 | 提供分析偏好 | 可选择综合/短线/中长线和规则/AI/混合/对照,并在本地保存默认值 |
|
||||||
| FR-005 | P1 | 展示市场证据模块 | 指数、宽度、量能、板块、情绪和资金摘要可阅读 |
|
| FR-005 | P1 | 展示市场证据模块 | 指数、宽度、量能、板块、情绪和资金摘要可阅读 |
|
||||||
@@ -50,10 +50,16 @@
|
|||||||
|
|
||||||
## 数据与口径
|
## 数据与口径
|
||||||
|
|
||||||
首个切片使用确定性模拟快照。字段仍必须包含交易日期、快照时间、来源状态、完整性和单位,以便以后无结构变更地替换为后端 API。
|
首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;市场宽度使用三市场指数快照,板块使用一级行业目录与日行情,量能使用三市场指数成交额汇总。主力净流入和盘中新闻不在本切片 TDX 主链路中,必须保持明确的补充源边界,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。
|
||||||
|
|
||||||
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
||||||
|
|
||||||
|
TDX 交易日期必须通过真实公历日期校验。成交额当前/前一日金额必须与同索引日期对齐,且前一交易日严格早于当前交易日;不满足条件的市场不参与汇总,也不得抢占最新日期截面。
|
||||||
|
|
||||||
|
指数和板块涨跌幅必须同时具备有效且严格递增的相邻日期。前一根日期非法、重复或晚于当前日期时,指数只展示当前价而不生成涨跌信号,板块不进入强弱排序。
|
||||||
|
|
||||||
|
首个切片不引入交易日历依赖:指数、一级行业和成交额的最新交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;市场宽度快照缺少日期字段,使用同批核心指数日线作为日期参考。周末和法定休市日会保守显示延迟并同时展示实际交易日期,后续接入权威交易日历后再细化“最新交易日”语义。
|
||||||
|
|
||||||
## 状态与异常
|
## 状态与异常
|
||||||
|
|
||||||
- 交易中:显示分钟级更新时间。
|
- 交易中:显示分钟级更新时间。
|
||||||
@@ -77,7 +83,7 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百
|
|||||||
- Given 页面加载有效快照,When 用户进入市场概览,Then 结论、驱动、风险和更新时间在首屏可见。
|
- Given 页面加载有效快照,When 用户进入市场概览,Then 结论、驱动、风险和更新时间在首屏可见。
|
||||||
- Given 用户打开分析状态入口,When 切换周期或引擎,Then 页面状态立即更新且可选择设为默认。
|
- Given 用户打开分析状态入口,When 切换周期或引擎,Then 页面状态立即更新且可选择设为默认。
|
||||||
- Given 数据快照被标记为延迟,When 页面渲染,Then 最近有效值保留并明确显示延迟,不能显示“实时”。
|
- Given 数据快照被标记为延迟,When 页面渲染,Then 最近有效值保留并明确显示延迟,不能显示“实时”。
|
||||||
- Given 用户点击查看依据,When 抽屉打开,Then 能看到指标名称、数值、口径和更新时间。
|
- Given 用户看到查看依据入口,When 后续依据抽屉功能立项,Then 能看到指标名称、数值、口径和更新时间;本切片只保留入口。
|
||||||
|
|
||||||
## 风险与依赖
|
## 风险与依赖
|
||||||
|
|
||||||
@@ -92,3 +98,5 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百
|
|||||||
## 变更记录
|
## 变更记录
|
||||||
|
|
||||||
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
|
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
|
||||||
|
- 0.2:根据 TDX 实连验证修正数据源、可用性边界和未接入基础设施范围。
|
||||||
|
- 0.3:核对官方行情接口后确认市场宽度、一级行业板块和成交额趋势可由 TDX 直接提供;主力净流入与盘中新闻保留补充源边界。
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# FEAT-MO-001 测试结果
|
||||||
|
|
||||||
|
## 自动化结果
|
||||||
|
|
||||||
|
- 前端 Vitest:8 个文件、20 个测试通过。
|
||||||
|
- 后端 Vitest:11 个文件、67 个测试通过;API 启动器 Node 测试 2/2 通过,Schema、治理、边界和部署门禁通过。
|
||||||
|
- `pnpm verify:e2e`:桌面/移动端页面和健康契约共 4 个测试通过。
|
||||||
|
- `pnpm typecheck`、`pnpm build`、`pnpm format:check`:通过。
|
||||||
|
- ESLint:0 error,保留 35 条 Vue 风格 warning,不阻断门禁。
|
||||||
|
|
||||||
|
## 手工结果
|
||||||
|
|
||||||
|
- `GET /api/v1/market-overview/indices` 和 `summary` 返回真实 TDX `fresh` 快照。
|
||||||
|
- TDX 隧道未监听时,五个模块均返回明确不可用状态,未伪造指标;切换到家庭 LAN 隧道后,`summary`、`indices`、`breadth`、`sectors`、`funds` 均返回 `source=tdx,status=fresh`。
|
||||||
|
- 家庭 LAN 真实冒烟中,指数返回 3 项、板块返回 8 项、成交额覆盖 3 个市场且交易日期完整;未保存真实价格或成交额数值。
|
||||||
|
- 修复日期对齐和延迟状态后再次通过家庭 LAN 真实冒烟,五个接口在当日数据下仍为 `fresh` 且无 warning。
|
||||||
|
- 第三轮 Review 修复后,非法月份/日期、倒序日期对和日期/金额数组错位均被映射层排除;服务测试统一冻结时钟,不依赖执行当天。
|
||||||
|
- 第四轮 Review 修复后,指数前一根非法、重复或倒序日期不再生成涨跌幅,板块对应记录不进入强弱排序。
|
||||||
|
- 桌面默认视口与 390px 移动视口无横向溢出;视角和引擎切换可用。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# FEAT-MO-001 视觉 QA
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
- 桌面默认视口:顶部应用识别、结论区、驱动/风险、核心指数和不可用模块均可见。
|
||||||
|
- 移动视口:390×844,首屏按单列排列,标题、状态、指数和模块说明不遮挡。
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 通过:2026-08-18 使用本地 API/Web 实际页面复验;桌面视口为 `1440×900`,移动视口为 `390×844`。
|
||||||
|
- 通过:桌面 `clientWidth=1440, scrollWidth=1440`,移动端 `clientWidth=390, scrollWidth=390`;移动端六个主要区块左右边界均位于 `14px~376px`,未发生页面级或区块级横向溢出。
|
||||||
|
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
|
||||||
|
- 通过:实际切换到“综合”和“混合”后,按钮激活态和下拉选中项同步更新。
|
||||||
|
- 通过:家庭 LAN 隧道 `127.0.0.1:17710` 下完成真实 TDX 行情复验;摘要、3 个核心指数、市场宽度、8 个一级行业板块和 3 市成交额均显示 `source=tdx`、`status=fresh`,无模块警告。
|
||||||
|
- 通过:桌面与移动端浏览器控制台均无 `warning` 或 `error`;移动端仅有 Vite 开发服务器连接调试信息。
|
||||||
|
- 通过:真实行情移动端 `clientWidth=390, scrollWidth=390`,无页面级横向溢出、内部文字截断或控件重叠;长风险说明、指数数值、板块标签和成交额均完整展示。
|
||||||
|
|
||||||
|
## 残余项
|
||||||
|
|
||||||
|
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。真实 TDX 接口值、交易日期和模块状态的逐项证据见 `tdx-smoke-report.md`;本次视觉复验使用同一家庭 LAN 隧道和真实接口响应,不使用离线样本伪装实时行情。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# FEAT-MO-001 发布说明
|
||||||
|
|
||||||
|
## 本次发布
|
||||||
|
|
||||||
|
- 新增 TDX 直连的市场概览首屏和五个模块 API。
|
||||||
|
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
|
||||||
|
- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。
|
||||||
|
- 指数和板块按最新交易日截面对齐,成交额按最新有效交易日期对汇总;后端根据上海当前日历日期实际生成 `delayed`,市场宽度使用同批核心指数日期参考。
|
||||||
|
- 拒绝非法公历日期、倒序交易日期以及日期/金额数组错位的 TDX 数据,避免异常市场抢占最新截面或产生时间倒置的成交额趋势。
|
||||||
|
- 指数和板块仅使用有效且严格递增的相邻 K 线日期计算涨跌,前一日期非法、重复或倒序时不生成市场强弱信号。
|
||||||
|
- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。
|
||||||
|
|
||||||
|
## 明确不包含
|
||||||
|
|
||||||
|
- 不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
||||||
|
- 不包含用户体系、详情页、依据抽屉、交易操作和荐股能力。
|
||||||
|
- 不包含主力净流入、盘中新闻、真实图表和详情页;这些能力需另立补充数据源和功能需求。
|
||||||
@@ -2,4 +2,12 @@
|
|||||||
|
|
||||||
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
|
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
|
||||||
|
|
||||||
当前切片仅开发 Vue 3 可交互首屏和本地模拟数据,不接用户体系和正式数据链路。
|
当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数、市场宽度、板块和成交额趋势;主力净流入与盘中新闻不作为本次 TDX 主链路指标,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
||||||
|
|
||||||
|
## 本次实际范围
|
||||||
|
|
||||||
|
- 已完成综合、短线、中长线视角切换,以及规则、AI、混合、对照偏好选择;偏好只保存于浏览器本地。
|
||||||
|
- 已完成按摘要、指数、宽度、板块、资金拆分的五个 HTTP 接口和前端 API 封装。
|
||||||
|
- 已完成 TDX 请求超时、响应大小限制、错误脱敏和模块级失败隔离。
|
||||||
|
- 已确认并接入 TDX 市场宽度、一级行业板块和成交额趋势;资金模块明确不等同于主力净流入,`unchanged` 无可靠总数时返回 `null`。
|
||||||
|
- 详情页和“查看依据”抽屉只保留入口,另立需求实现,不在本次发布范围内。
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 代码 Review
|
||||||
|
|
||||||
|
## 首轮独立 Review
|
||||||
|
|
||||||
|
- 对比基线:`8261948`
|
||||||
|
- 审查提交:`ff8ada8`
|
||||||
|
- 结论:`Ready to merge: No`
|
||||||
|
|
||||||
|
首轮发现及处置:
|
||||||
|
|
||||||
|
1. 畸形 `UpHome/DownHome` 被折算为零并误标 `fresh`:改为有限、非负且成对校验,无效市场整体排除并按有效市场数返回 `fresh/partial/unavailable`。
|
||||||
|
2. 成交额缺市场或交易日错位仍误标三市实时数据:只汇总非负金额和一致交易日,公开 `marketCount`、`tradeDate`、`previousTradeDate`,缺市场返回 `partial`。
|
||||||
|
3. 空板块目录误标 `fresh`:一级行业为空时直接返回 `unavailable`,且只保留存在相邻两日收盘价和有效涨跌幅的板块。
|
||||||
|
4. 前端隐藏 `partial/delayed` 警告:宽度、板块和成交额面板均展示非 `fresh` 状态提示,并展示板块与成交额真实交易日期。
|
||||||
|
5. PRD、发布说明和 Review 文档残留旧口径:已同步更正为真实 TDX 宽度、一级行业与成交额趋势口径,主力净流入和新闻仍明确延期。
|
||||||
|
6. 异常分支测试不足:新增畸形宽度、部分市场、错位交易日、空行业目录、额外字段隔离、前端状态提示和零变化中性色回归测试。
|
||||||
|
|
||||||
|
次要问题也已同步处理:OpenAPI `required + nullable` 定义、板块 DTO 额外 `price` 字段泄露、成交额零变化上涨色和新增映射中文口径注释。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
覆盖 TDX 客户端、市场概览后端模块、OpenAPI/前端契约、Axios API 封装、Vue 首屏组件和对应测试。
|
||||||
|
|
||||||
|
## 检查结果
|
||||||
|
|
||||||
|
- 通过:API 按模块拆分,前端请求集中在 `web/src/api/market-overview/index.ts`,Vue 不直连 TDX。
|
||||||
|
- 通过:真实指数和摘要字段经过 DTO/映射;桥接层未提供的方法返回结构化 `unavailable`,没有模拟数据冒充。
|
||||||
|
- 通过:模块并行加载且独立处理异常;TDX 请求有固定回环地址、超时、响应大小限制和错误脱敏。
|
||||||
|
- 通过:未将密码、令牌、行情原始载荷或生产数据写入代码、文档和日志。
|
||||||
|
- 通过:E2E 测试夹具显式声明回环 TDX 配置但不要求 TDX 可达,避免测试环境绕过正式配置校验。
|
||||||
|
|
||||||
|
## 安全与残余风险
|
||||||
|
|
||||||
|
- TDX 只读边界固定为回环地址,客户端限制超时和响应大小并脱敏异常;未接入交易、AI 密钥、数据库写入或生产数据。
|
||||||
|
- 残余风险是 TDX 桥接方法覆盖不全、市场数据授权和生产 7×24 稳定性尚未验证,已在需求和回退说明中标明。
|
||||||
|
|
||||||
|
## GitNexus 与同步修改
|
||||||
|
|
||||||
|
开发前已保存 GitNexus 影响计划。早期提交曾映射 11 条执行流并标记 high;`49f6be9` 修复提交的 staged 检测映射 20 个文件、67 个符号、16 条执行流并标记 critical。critical 来自市场概览核心服务、TDX 映射和前端数据面板,不是未知跨业务扩散;未发现数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境未暴露 GitNexus MCP 变更检测工具,已使用同版本 CLI 和 worktree 绝对路径保留证据。
|
||||||
|
|
||||||
|
## 性能与资源影响
|
||||||
|
|
||||||
|
- 五个模块并行请求,单请求超时并限制 1 MiB 响应;市场宽度并行增加一次单指数、单日线日期参考请求,不引入 MySQL、Redis、RocketMQ 在线负载。
|
||||||
|
- 未进行 7×24、断线重连和生产压测;主力净流入、盘中新闻、权威交易日历及详情页仍待独立功能。
|
||||||
|
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
|
||||||
|
|
||||||
|
## 二次独立 Review
|
||||||
|
|
||||||
|
- 审查范围:`ff8ada8..49f6be9`
|
||||||
|
- 结论:`Ready to merge: No`
|
||||||
|
- Critical:无。
|
||||||
|
- Important:宽度仍接受小数/十六进制等非计数值;成交额以首个市场日期为锚;板块跨交易日混排仍标 `fresh`;后端没有真实 `delayed` 路径。
|
||||||
|
- Minor:GitNexus 风险/执行流和 TDX 已接入范围存在旧口径。
|
||||||
|
|
||||||
|
上述问题已按 TDD 增加失败用例后修复:宽度只接受十进制非负安全整数;指数/板块只保留最新交易日;成交额选择最新有效日期对;四个后端日行情路径均具备 `delayed` 判定,宽度使用同批指数日期参考。修复后等待再次独立 Review。
|
||||||
|
|
||||||
|
## 第三轮独立 Review
|
||||||
|
|
||||||
|
- 审查范围:`49f6be9..7e4dcd9`
|
||||||
|
- 结论:`Ready to merge: No`
|
||||||
|
- Critical:无。
|
||||||
|
- Important:8 位数字日期未验证真实公历语义,成交额未强制前一交易日早于当前日且未验证日期/金额数组对齐;服务测试固定使用 `20260818` 行情却没有统一冻结系统时间,会在自然日期变化后失败。
|
||||||
|
- Minor:开发总结未包含 `7e4dcd9`,GitNexus 仍保留上一轮 16 条执行流口径。
|
||||||
|
|
||||||
|
处置:按 TDD 先得到 3 个预期失败,再增加真实公历日期、日期顺序和数组对齐校验;服务测试套件统一冻结上海日期,并由单个延迟用例显式切换。修复后后端 Vitest `63/63`、启动器 `2/2` 和类型检查通过,等待再次独立 Review。
|
||||||
|
|
||||||
|
## 第四轮独立 Review
|
||||||
|
|
||||||
|
- 审查范围:`49f6be9..ae2a7e0`
|
||||||
|
- 结论:`Ready to merge: No`
|
||||||
|
- Critical:无。
|
||||||
|
- Important:指数和板块只校验当前 K 线日期,前一根日期非法、重复或倒序时仍计算涨跌;最终治理材料仍处于 `pending`,尚未执行 `governance:finish`。
|
||||||
|
- Minor:无新增。
|
||||||
|
|
||||||
|
处置:按 TDD 先得到 4 个预期失败,再要求前一日期有效且严格早于当前日期;无可比前收盘时指数保留当前价但不生成涨跌,板块不进入强弱排序。后端 Vitest 增加至 `67/67`。治理状态保持 `pending` 是有意的门禁顺序:需本修复再次独立 Review 通过后,才能合法写入 `passed` 并运行 `governance:finish`。
|
||||||
|
|
||||||
|
## 第五轮最终独立 Review
|
||||||
|
|
||||||
|
- 审查范围:`ae2a7e0..c2fdfa6`
|
||||||
|
- 结论:`Ready to merge: Yes`
|
||||||
|
- Critical:无。
|
||||||
|
- Important:无;上一轮指数/板块前一根 K 线日期校验问题已关闭。
|
||||||
|
- Minor:无。
|
||||||
|
- 验证:指数非法、重复、倒序前一日期参数化用例和板块过滤用例齐全;后端 Vitest `67/67`、启动器 `2/2`、服务端类型检查与 `git diff --check` 均通过。安全、性能及 PRD/设计/测试/QA 口径一致。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 开发规范符合性 Review
|
||||||
|
|
||||||
|
## 安全、性能与架构检查
|
||||||
|
|
||||||
|
- 已建立并更新 PRD、需求、设计、实施计划、测试计划、开发总结和 Review 文档。
|
||||||
|
- 已记录安全策略、性能目标、GitNexus 影响、验证证据、范围变化和残余风险。
|
||||||
|
- 后端按业务模块组织并使用 DTO/映射;前端请求通过统一 Axios 封装,业务 API 独立存放。
|
||||||
|
- 代码注释集中在配置边界、异常恢复和偏好存储等非显然逻辑,没有逐行噪声注释。
|
||||||
|
- 未启用 MySQL、Redis、RocketMQ;本切片没有伪造基础设施已接入,也没有触碰生产数据库。
|
||||||
|
- Git 提交和推送将在 Review 通过后按中文 Conventional Commit 执行;不修改 Git 全局配置。
|
||||||
|
|
||||||
|
## GitNexus 与开发总结检查
|
||||||
|
|
||||||
|
- 开发前影响计划、开发后索引、变更检测和风险处置已归档。
|
||||||
|
- 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。
|
||||||
|
- QA、发布说明和回退说明与本功能档案保持同一功能编号。
|
||||||
|
|
||||||
|
## 首轮问题同步检查
|
||||||
|
|
||||||
|
- 数据完整性、状态语义、OpenAPI nullable、DTO 输出边界和前端降级提示均已补齐回归测试。
|
||||||
|
- PRD、发布说明、测试计划和开发总结已同步实际口径,避免实现与文档分叉。
|
||||||
|
- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。
|
||||||
|
- 二次独立 Review 发现的严格整数、日期对齐和真实 `delayed` 语义已按 TDD 修复;后端新增 9 个断言并完成家庭 LAN 当日行情复验。
|
||||||
|
- 新鲜度暂按上海当前日历日期保守判断,周末和法定休市日可能显示 `delayed`;该残余边界已同步 PRD、设计和测试计划,不伪装成交易日历已接入。
|
||||||
|
- 第三轮 Review 的日期真实性、先后顺序、数组对齐和测试时钟问题已按 TDD 修复;需求、设计、测试计划、测试结果、发布说明和开发总结已同步。
|
||||||
|
- 第四轮 Review 的前一根日期校验问题已按 TDD 修复;治理结论继续保持 `pending`,等待本次代码修复再次独立确认。
|
||||||
|
- 第五轮最终独立 Review 无 Critical、Important 或 Minor 问题并给出 `Ready to merge: Yes`;中文提交、Gitea 推送、GitNexus、完整门禁和视觉证据均已复核。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# FEAT-MO-001 回退说明
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
- TDX 请求错误导致核心指数无法展示或页面加载阻断。
|
||||||
|
- 发现前端横向溢出、来源状态误标为实时,或不可用指标被伪造。
|
||||||
|
- 生产部署验证发现配置边界、权限或错误脱敏回归。
|
||||||
|
|
||||||
|
## 操作
|
||||||
|
|
||||||
|
1. 停止当前 API/Web systemd 服务,保留日志和失败快照摘要,不保存原始行情载荷。
|
||||||
|
2. 回退到上一个已推送的 Gitea 提交/标签,重新执行源码构建和 `pnpm verify:source`。
|
||||||
|
3. 恢复 TDX 桥接隧道后只做只读接口冒烟;确认通过前不得重新对外开放。
|
||||||
|
|
||||||
|
## 数据边界
|
||||||
|
|
||||||
|
本功能没有写入 MySQL、Redis 或 RocketMQ,也没有数据库迁移;回退不执行生产数据库操作。开发 Schema 继续遵循仅读取开发库的门禁。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# FEAT-MO-001 TDX 只读冒烟报告
|
||||||
|
|
||||||
|
- 验证时间:2026-08-18 17:21 CST
|
||||||
|
- 网络场景:本机回环桥接(TDX 服务可达)
|
||||||
|
- 本机业务入口:`127.0.0.1:17710`
|
||||||
|
- API 入口:`127.0.0.1:3000/api/v1/market-overview`
|
||||||
|
- 请求方式:HTTP GET(业务 API)及桥接层 HTTP POST JSON
|
||||||
|
- 请求类型:市场宽度、一级行业板块、成交额趋势只读请求
|
||||||
|
- 原始载荷:未保存
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 三条业务接口均返回 HTTP `200`,响应状态均为 `fresh`,`source` 均为 `tdx`。
|
||||||
|
- `/breadth`:成功汇总上证、深证和北证指数快照中的 `UpHome`、`DownHome`;`unchanged` 返回 `null`,未进行总数推算。
|
||||||
|
- `/sectors`:成功读取一级行业目录并批量获取日行情,按涨跌幅排序返回前 8 项。
|
||||||
|
- `/funds`:成功汇总三个交易所指数日行情 `Amount`,按万元转换为亿元并计算较前一交易日变化;该指标展示为“成交额趋势”,不宣称主力净流入。
|
||||||
|
- 端到端耗时:市场宽度约 `0.11s`,一级行业约 `0.27s`,成交额趋势约 `0.12s`(单次本机冒烟,不代表生产 SLA)。
|
||||||
|
- 未调用交易、账户、自选、写入接口;未保存真实行情原始载荷。
|
||||||
|
|
||||||
|
## 家庭 LAN 二次复验
|
||||||
|
|
||||||
|
- 验证时间:2026-08-18 19:35 CST。
|
||||||
|
- 先按开发手册切换到家庭 LAN SSH 别名,确认本机 `127.0.0.1:17710` 的监听者为本次 SSH 隧道;平台配置仍只使用回环地址。
|
||||||
|
- 桥接层最小只读 HTTP POST 成功,没有调用交易、账户、自选或写入方法。
|
||||||
|
- Nest API 的 `/summary`、`/indices`、`/breadth`、`/sectors`、`/funds` 均返回 HTTP `200`、`source=tdx`、`status=fresh` 且无 warning。
|
||||||
|
- `/indices` 返回 3 项,`/sectors` 返回 8 项;`/breadth` 返回有限且非负的涨跌家数;`/funds` 返回 `marketCount=3`,当前和上一交易日期均存在且不同。
|
||||||
|
- 本次只记录结构、状态、数量和日期完整性,不记录真实价格、成交额数值、家庭网络地址、SSH 用户或原始响应。
|
||||||
|
- 完成严格整数、最新交易日截面和后端 `delayed` 修复后再次复验:五个接口在当日行情下仍为 `fresh` 且无 warning,证明新增新鲜度判断未误伤当前日期数据。
|
||||||
|
|
||||||
|
## 脱敏与限制
|
||||||
|
|
||||||
|
- 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。
|
||||||
|
- 本报告只证明当前开发机到 TDX 的只读接口和本次映射可达,不证明 7×24 生产采集、商业再分发授权、主力净流入口径或盘中新闻接口已经可用。
|
||||||
|
- 主力净流入和盘中新闻继续保留为补充数据源需求;后续接入必须单独记录来源、权限、口径和数据质量。
|
||||||
@@ -3,6 +3,36 @@
|
|||||||
- 结论、驱动和风险在首屏可见。
|
- 结论、驱动和风险在首屏可见。
|
||||||
- 分析状态可以临时切换并保存默认值。
|
- 分析状态可以临时切换并保存默认值。
|
||||||
- 依据抽屉能够打开和关闭。
|
- 依据抽屉能够打开和关闭。
|
||||||
- 时间范围切换更新图表。
|
- 五个模块并行请求,一个模块失败不阻断其他模块。
|
||||||
|
- 真实 TDX 指数、市场宽度、一级行业板块和成交额趋势响应可映射;主力净流入与盘中新闻不以占位值伪造。
|
||||||
- 加载、部分数据、过期和错误状态具有明确文案。
|
- 加载、部分数据、过期和错误状态具有明确文案。
|
||||||
- 桌面和窄窗口不出现阻断核心内容的溢出。
|
- 桌面和窄窗口不出现阻断核心内容的溢出。
|
||||||
|
|
||||||
|
## 已执行证据
|
||||||
|
|
||||||
|
- 前端 Vitest、typecheck、build:通过。
|
||||||
|
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
|
||||||
|
- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。
|
||||||
|
- 异常映射:覆盖畸形涨跌家数字段(小数、指数、十六进制和超出安全整数)、当前及前一根非法公历日期、相邻日期重复/倒序、空一级行业目录、单根板块 K 线、指数/板块混合交易日、成交额缺市场/交易日倒序/日期金额数组错位/负数/首市场落后,以及三市完整性状态。
|
||||||
|
- 后端状态:覆盖指数、市场宽度、一级行业和成交额旧交易日期的真实 `delayed` 分支;休市日保守延迟属于已记录边界。
|
||||||
|
- 测试时钟:市场概览服务套件统一冻结到上海 `2026-08-18`,单个 `delayed` 用例显式切换目标日期并在用例后恢复,保证测试不会随自然日期变化。
|
||||||
|
- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。
|
||||||
|
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
||||||
|
|
||||||
|
## 安全、性能与残余风险
|
||||||
|
|
||||||
|
- 安全:TDX 地址固定为本机回环桥接端口;请求超时、响应 1 MiB 上限、错误脱敏;只读行情,不接受用户输入拼接请求。
|
||||||
|
- 性能:五个模块并行请求,单次 TDX 请求受超时保护;本切片不引入数据库、缓存或消息负载,未进行持续压测。
|
||||||
|
- 残余风险:主力净流入字段当前桥接实测为零值,需权限和口径专项验证;盘中新闻仍需补充源;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。
|
||||||
|
|
||||||
|
## 安全验证
|
||||||
|
|
||||||
|
- 验证 TDX 只能使用回环地址和固定端口,错误响应不包含原始 URL、请求体或凭据。
|
||||||
|
- 验证前端只能通过统一 Axios API 文件访问后端,边界测试拒绝页面直连 Axios/fetch 和 Server 内部依赖。
|
||||||
|
- 验证 E2E 夹具使用无秘密的生产测试环境变量,不读取仓库内本地凭据或生产配置。
|
||||||
|
|
||||||
|
## 性能验证
|
||||||
|
|
||||||
|
- 验证五个模块并发加载、单模块失败隔离和请求超时行为。
|
||||||
|
- 验证响应体 1 MiB 上限和构建产物体积;浏览器桌面/移动端无横向溢出。
|
||||||
|
- 7×24 运行、峰值并发、TDX 断线重连和消息/缓存压力测试不适用于当前只读首屏,已列为后续采集与生产功能边界。
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ format v3 使用独立字段记录 Node 导出器名称、导出器版本、数
|
|||||||
|
|
||||||
例程、触发器和事件使用稳定 `DELIMITER`。标识符只能来自元数据,并统一转义反引号。导出结果删除 `DEFINER`、当前 `AUTO_INCREMENT` 值、时间、主机、用户名和客户端噪声;同一结构必须产生逐字节一致结果。
|
例程、触发器和事件使用稳定 `DELIMITER`。标识符只能来自元数据,并统一转义反引号。导出结果删除 `DEFINER`、当前 `AUTO_INCREMENT` 值、时间、主机、用户名和客户端噪声;同一结构必须产生逐字节一致结果。
|
||||||
|
|
||||||
## 4. 完整性与安全
|
## 4. 安全策略与评估
|
||||||
|
|
||||||
导出前后分别计算对象摘要,包含对象类型、名称、依赖和定义可观察版本;摘要不一致表示并发 DDL,命令失败。任何对象无法 `SHOW CREATE`、结果为空或类型未知时禁止跳过。
|
导出前后分别计算对象摘要,包含对象类型、名称、依赖和定义可观察版本;摘要不一致表示并发 DDL,命令失败。任何对象无法 `SHOW CREATE`、结果为空或类型未知时禁止跳过。
|
||||||
|
|
||||||
@@ -42,15 +42,15 @@ format v3 使用独立字段记录 Node 导出器名称、导出器版本、数
|
|||||||
|
|
||||||
现有 SQL 安全扫描继续拒绝顶层数据操作、连接串、疑似凭据和残留 `DEFINER`;对象体内部合法 DML 通过分隔符解析保留。
|
现有 SQL 安全扫描继续拒绝顶层数据操作、连接串、疑似凭据和残留 `DEFINER`;对象体内部合法 DML 通过分隔符解析保留。
|
||||||
|
|
||||||
## 5. 性能与异常恢复
|
## 5. 性能目标与评估
|
||||||
|
|
||||||
Schema 备份不在在线请求路径。使用单连接和有限并发,目标中小型开发库 30 秒内完成;连接、查询、总时长、对象数量或响应大小超过保护值时失败。
|
Schema 备份不在在线请求路径。使用单连接和有限并发,目标中小型开发库 30 秒内完成;连接、查询、总时长、对象数量或响应大小超过保护值时失败。
|
||||||
|
|
||||||
产物先写入版本父目录中的临时目录,经过 SQL 扫描、对象计数、manifest 和 SHA-256 一致性校验后原子重命名。失败时删除临时目录,既有版本目录禁止覆盖。
|
产物先写入版本父目录中的临时目录,经过 SQL 扫描、对象计数、manifest 和 SHA-256 一致性校验后原子重命名。失败时删除临时目录,既有版本目录禁止覆盖。
|
||||||
|
|
||||||
## 6. 实施迁移
|
## 6. 异常恢复与实施迁移
|
||||||
|
|
||||||
GOV-012 因缺少 `v0.0.9` 基线使用用户授权的一次性人工启动记录。实现通过后立即运行新导出器生成并推送 `v0.0.9` 基线,再重新执行自动 `governance:start`,证明后续开发不再依赖本机 MySQL CLI。
|
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. 依赖评估
|
## 7. 依赖评估
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 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`
|
||||||
|
- `58026ae`、`16bde94`、`11edeb0`
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- `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 已提交并推送。
|
||||||
|
- 发布 `v0.0.10` 标签已推送;发布后 `v0.0.10` `captured_empty` manifest 已按标签提交校验并推送 `main`。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# GitNexus 开发后基线
|
||||||
|
|
||||||
|
- 功能编号:GOV-012
|
||||||
|
- 记录时间:2026年08月18日 CST
|
||||||
|
- 分支:`codex/gov-012-node-schema-exporter`
|
||||||
|
- 当前提交:`11edeb0`
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Indexed commit: f1c412b
|
||||||
|
Nodes: 2,431
|
||||||
|
Edges: 3,282
|
||||||
|
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`;没有生产数据库访问。
|
||||||
|
- `v0.0.10` 标签指向发布提交 `16bde94`,发布后基线提交为 `11edeb0`;两份基线均已推送 Gitea `main`。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# GitNexus 前后对比
|
||||||
|
|
||||||
|
| 项目 | 开发前 | 开发后 | 判断 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Git 提交 | `3555b7c` | `11edeb0` | GOV-012 实现、发布和 v0.0.10 基线均已提交 |
|
||||||
|
| 索引规模 | 2,112 nodes / 2,790 edges / 50 flows | 2,431 nodes / 3,282 edges / 63 flows | 仅新增导出器、测试、文档和基线清单相关符号 |
|
||||||
|
| MCP | `Transport closed` | `Transport closed` | 使用 CLI、静态引用和回归测试降级核对 |
|
||||||
|
| 数据库范围 | 缺失 `v0.0.9` 基线 | `a_share_analysis_dev`,v0.0.9/v0.0.10 均 `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 传输故障作为残余工具风险保留,不解释为无影响。
|
||||||
@@ -34,3 +34,35 @@
|
|||||||
## 高风险处置
|
## 高风险处置
|
||||||
|
|
||||||
这是全部版本开发的关键路径,风险至少为 HIGH。修改任何相关函数前必须重新执行 GitNexus upstream impact;若 MCP 仍不可用,使用静态调用关系、现有 50 条流程和全量回归交叉验证。历史基线失败、数据语句进入 SQL、生产边界被放宽或自动门禁未恢复时立即停止发布并回退。
|
这是全部版本开发的关键路径,风险至少为 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,则本例外不适用并立即阻断。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id: GOV-012
|
id: GOV-012
|
||||||
name: 纯 Node.js 开发库 Schema 导出器
|
name: 纯 Node.js 开发库 Schema 导出器
|
||||||
slug: node-schema-exporter
|
slug: node-schema-exporter
|
||||||
status: approved
|
status: released
|
||||||
owner: project
|
owner: project
|
||||||
created_at: 2026-08-18
|
created_at: 2026-08-18
|
||||||
requirements: requirements.md
|
requirements: requirements.md
|
||||||
@@ -9,4 +9,4 @@ prd: prd.md
|
|||||||
design: design.md
|
design: design.md
|
||||||
implementation_plan: implementation-plan.md
|
implementation_plan: implementation-plan.md
|
||||||
traceability_note: 设计已确认;实施计划需在用户复核书面规格后创建
|
traceability_note: 设计已确认;实施计划需在用户复核书面规格后创建
|
||||||
release: unreleased
|
release: v0.0.10
|
||||||
|
|||||||
@@ -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,22 @@
|
|||||||
|
# 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 基线。
|
||||||
|
|
||||||
|
## 发布结果
|
||||||
|
|
||||||
|
- `v0.0.10` 标签指向 `16bde94`,已单独推送 Gitea。
|
||||||
|
- 发布后 `v0.0.10` Schema 基线提交为 `11edeb0`,状态为 `captured_empty`,已推送 `main`。
|
||||||
@@ -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
|
||||||
@@ -25,8 +25,13 @@
|
|||||||
- 当前为空时生成 `captured_empty`;发现实际对象时生成完整 `captured`,不得按预期强制为空。
|
- 当前为空时生成 `captured_empty`;发现实际对象时生成完整 `captured`,不得按预期强制为空。
|
||||||
- 运行 `schema:verify`、治理测试、格式、lint、类型检查和完整仓库验证。
|
- 运行 `schema:verify`、治理测试、格式、lint、类型检查和完整仓库验证。
|
||||||
|
|
||||||
## 安全与性能
|
## 安全验证
|
||||||
|
|
||||||
- 证明没有子进程数据库客户端调用、业务表数据查询、生产配置读取或多语句执行。
|
- 证明没有子进程数据库客户端调用、业务表数据查询、生产配置读取或多语句执行。
|
||||||
- 固定夹具验证对象数量、响应大小、查询和总时长保护。
|
- 固定夹具验证对象数量、响应大小、查询和总时长保护。
|
||||||
- 残余风险包括真实库权限差异和并发 DDL;通过失败关闭、摘要复核和人工重跑控制。
|
- 残余风险包括真实库权限差异和并发 DDL;通过失败关闭、摘要复核和人工重跑控制。
|
||||||
|
|
||||||
|
## 性能验证
|
||||||
|
|
||||||
|
- 假连接验证单连接、5 秒查询超时、30 秒总时长、5,000 对象和 1 MiB 单定义限制;真实开发库记录导出状态和耗时,不记录 SQL 或凭据。
|
||||||
|
- 本功能是低频治理命令,不在 API 请求路径,也不承载在线行情吞吐;因此不执行业务负载压测,边界是版本基线生成和验证命令本身。
|
||||||
|
|||||||
@@ -144,11 +144,13 @@ count += 1;
|
|||||||
### Schema 基线规范
|
### Schema 基线规范
|
||||||
|
|
||||||
- 每个新应用版本开发前必须封存上一已发布版本的开发库 Schema;只能读取 `APP_ENV=development`、`a_share_analysis_dev` 和 MySQL 5.7.x,主机仅允许回环地址或固定开发白名单 `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.x `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. 测试要求
|
||||||
|
|
||||||
|
|||||||
@@ -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. 提交流程
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
# 市场综合概览 TDX 直连实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task with review checkpoints.
|
||||||
|
|
||||||
|
**Goal:** 使用 Nest API 直接读取本机 TDX 隧道,在 Vue 3 首页提供可独立降级的市场综合概览。
|
||||||
|
|
||||||
|
**Architecture:** API 服务通过固定的 `127.0.0.1:17710` TDX HTTP 客户端读取只读行情,将 TDX 原始字段映射为版本化市场概览 DTO。首页按摘要、指数、宽度、板块和资金拆分请求,通过 Axios API 文件消费 Nest 接口;浏览器不直接连接 TDX。
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3、Vue Router、Pinia、Naive UI、Dart Sass、NestJS 11、TypeScript 5.9、Node.js 22.x 内置 `fetch`、OpenAPI/Swagger、Vitest、Playwright。
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Node 使用 `22.x`,MySQL 兼容 `5.7.x`,字符集排序规则为 `utf8mb4_general_ci`。
|
||||||
|
- TDX 只读访问固定到服务端 `http://127.0.0.1:17710/`,不得由请求参数传入任意 URL。
|
||||||
|
- TDX 不可用时显示 `unavailable`,不得使用离线样本或伪造实时行情回退。
|
||||||
|
- 前端业务请求只能位于 `web/src/api/<业务域>/index.ts`,统一调用 `web/src/utils/request.ts` 的 Axios 封装。
|
||||||
|
- 业务代码必须提供准确中文注释;不以原子化为目的过度拆分文件。
|
||||||
|
- 本功能不启用 MySQL、Redis、RocketMQ、AI、用户体系、交易指令或公网采集链路。
|
||||||
|
- 不提交 SSH 地址、Tailscale 地址、密钥、密码、`.env`、真实原始 TDX 载荷或生产数据。
|
||||||
|
- 修改符号前必须完成 GitNexus 影响分析;提交前必须执行 GitNexus 变更检测。
|
||||||
|
- 每个提交使用中文 Conventional Commit,并立即推送当前分支到项目 Gitea。
|
||||||
|
|
||||||
|
## 文件地图
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- Modify: `server/libs/config/src/runtime-config.ts`,增加 API 专属 TDX 地址和超时配置及严格校验。
|
||||||
|
- Modify: `deploy/env/development/api.env.example`,增加不含凭据的 `TDX_BASE_URL` 与 `TDX_TIMEOUT_MS` 模板。
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.types.ts`,记录已验证的 TDX 请求、响应和错误类型。
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.client.ts`,封装固定地址、超时、响应大小和脱敏错误。
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.mapper.ts`,转换 TDX 字段和数据口径。
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.dto.ts`,定义 OpenAPI/HTTP 响应 DTO。
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.service.ts`,并行获取模块数据并生成统一快照元数据。
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.controller.ts`,提供五个只读接口。
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.module.ts`,装配 TDX 客户端、服务和控制器。
|
||||||
|
- Modify: `server/apps/api/src/api.module.ts`,注册市场概览模块并注入 API 配置。
|
||||||
|
- Modify: `server/tools/export-openapi.ts`,让 OpenAPI 生成器包含市场概览控制器及其测试替身。
|
||||||
|
- Modify: `server/openapi/openapi.json`,提交生成后的契约。
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.client.test.ts`,测试 TDX 请求、超时、网络错误和响应上限。
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts`,测试字段映射、单位和缺失字段。
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.service.test.ts`,测试并行接口、快照时间和模块级降级。
|
||||||
|
- Create: `server/apps/api/test/market-overview.e2e-spec.ts`,测试 HTTP 状态、响应结构和错误映射。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- Modify: `web/src/api/generated/schema.ts`,由 OpenAPI 生成命令更新。
|
||||||
|
- Create: `web/src/types/market-overview.ts`,定义页面消费的类型别名和显示状态。
|
||||||
|
- Create: `web/src/api/market-overview/index.ts`,集中封装五个 Axios 请求。
|
||||||
|
- Create: `web/src/views/market-overview/useMarketOverview.ts`,管理并行加载、刷新、模块状态和分析偏好本地存储。
|
||||||
|
- Create: `web/src/views/market-overview/MarketOverviewPage.vue`,组织首屏布局和页面级状态。
|
||||||
|
- Create: `web/src/views/market-overview/components/MarketConclusion.vue`,显示结论、交易状态、更新时间和来源。
|
||||||
|
- Create: `web/src/views/market-overview/components/MarketDrivers.vue`,显示驱动与风险证据。
|
||||||
|
- Create: `web/src/views/market-overview/components/IndexOverview.vue`,显示可点击核心指数。
|
||||||
|
- Create: `web/src/views/market-overview/components/MarketBreadth.vue`,显示涨跌分布、宽度和量能。
|
||||||
|
- Create: `web/src/views/market-overview/components/SectorOverview.vue`,显示可点击板块排行。
|
||||||
|
- Create: `web/src/views/market-overview/components/FundTrend.vue`,显示资金趋势和不可用状态。
|
||||||
|
- Modify: `web/src/app/router.ts`,将 `/` 指向市场概览页面。
|
||||||
|
- Modify: `web/src/app/App.vue` 与 `web/src/styles/index.scss`,更新应用壳和响应式页面基础样式。
|
||||||
|
- Create: `web/src/api/market-overview/index.test.ts`,测试 API 路径、方法、参数和错误脱敏。
|
||||||
|
- Create: `web/src/views/market-overview/useMarketOverview.test.ts`,测试并行加载、刷新和偏好保存。
|
||||||
|
- Modify: `web/src/app/App.test.ts`,改为验证市场概览首屏语义和状态入口。
|
||||||
|
- Modify: `web/tests/smoke.spec.ts`,增加桌面、窄屏、模块降级和无水平溢出验证。
|
||||||
|
|
||||||
|
### 功能档案
|
||||||
|
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/requirements.md`,同步 TDX 直连和分模块接口范围。
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/design.md`,链接本设计并移除旧的样本默认表述。
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/implementation-plan.md`,更新为已执行任务索引。
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/test-plan.md`,加入 TDX 联调、模块降级、安全和性能证据。
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/development-summary.md`,记录原始需求、实际完成、缺陷、延期项、提交和验证证据。
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/review/code-review.md`,记录代码 Review 结论和残余风险。
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/review/standards-review.md`,记录开发规范符合性 Review。
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md`,只保存脱敏的真实 TDX 字段完整性、耗时和错误分类。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 开发门禁、工作区和 TDX 协议基线
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/gitnexus/impact-plan.md`
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/gitnexus/implementation-baseline.md`
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 已确认设计文档、现有 Tailscale SSH 隧道、开发环境 API 配置。
|
||||||
|
- Produces: 新分支和工作区、GitNexus 影响证据、TDX 只读请求方法及脱敏字段基线。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建隔离工作区并确认远程状态**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin main
|
||||||
|
git worktree add .worktrees/feat-mo-001-tdx-overview -b codex/feat-mo-001-tdx-overview origin/main
|
||||||
|
git -C .worktrees/feat-mo-001-tdx-overview status --short --branch
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:新工作区基于已推送的 `9a965fa`,不携带主工作区的 `AGENTS.md` 未提交修改。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 执行开发前门禁并记录结果**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm schema:baseline
|
||||||
|
pnpm schema:verify
|
||||||
|
npx gitnexus analyze
|
||||||
|
npx gitnexus impact --repo /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis --scope upstream "ApiModule.register"
|
||||||
|
npx gitnexus impact --repo /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis --scope upstream "createAppRouter"
|
||||||
|
npx gitnexus impact --repo /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis --scope upstream "createRequestClient"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:Schema 命令只读取开发环境;若本机缺少 MySQL 5.7.x 客户端,沿用已确认的 FEAT-MO-001 一次性 waiver 记录失败,不伪造基线。任何 HIGH/CRITICAL 影响先记录并暂停代码修改。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 用当前 TDX 隧道执行最小只读协议请求**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail-with-body --silent --show-error \
|
||||||
|
--connect-timeout 3 --max-time 10 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X POST 'http://127.0.0.1:17710/' \
|
||||||
|
--data '{"id":1,"method":"get_market_data","params":{"stock_list":["600519.SH"],"count":5,"dividend_type":"none","period":"1d"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:记录实际 HTTP 状态、业务错误码、响应耗时、字段名称和字段完整性;报告不得保存原始载荷、地址、账户或密钥。若方法不可用,按 TDX 返回的公开错误信息调整为仓库文档支持的只读方法,并把最终方法名写入 `tdx.types.ts` 和冒烟报告。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交门禁和协议基线**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/features/FEAT-MO-001-market-overview/gitnexus docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md
|
||||||
|
git commit -m "docs(FEAT-MO-001): 建立TDX概览开发基线"
|
||||||
|
git push -u origin codex/feat-mo-001-tdx-overview
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:提交只包含治理和脱敏报告,不包含真实 TDX 原始 JSON。
|
||||||
|
|
||||||
|
### Task 2: 配置和 TDX 客户端
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/libs/config/src/runtime-config.ts`
|
||||||
|
- Modify: `deploy/env/development/api.env.example`
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.types.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.client.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.client.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1 的已验证只读请求方法和字段基线。
|
||||||
|
- Produces: `ApiRuntimeConfig.tdx`、`TdxClient.request<TResponse>()`、`TdxClientError`。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 先写配置和客户端失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('拒绝非本机 TDX 地址', () => {
|
||||||
|
expect(() => loadApiConfig({ ...validEnv, TDX_BASE_URL: 'http://example.test' })).toThrow(
|
||||||
|
'TDX_BASE_URL 必须指向本机回环地址',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将 TDX 超时转换为脱敏错误', async () => {
|
||||||
|
const client = createTdxClient({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 50 }, timeoutFetch);
|
||||||
|
await expect(client.request('get_market_data', { stock_list: ['600519.SH'] })).rejects.toMatchObject({
|
||||||
|
kind: 'timeout',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行定向测试确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/server exec vitest run apps/api/src/market-overview/tdx/tdx.client.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:因配置字段、客户端工厂和错误类型不存在而失败。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现最小配置和客户端**
|
||||||
|
|
||||||
|
`ApiRuntimeConfig` 增加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
tdx: Readonly<{
|
||||||
|
baseUrl: 'http://127.0.0.1:17710';
|
||||||
|
timeoutMs: number;
|
||||||
|
}>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`TdxClient` 暴露:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TdxClient {
|
||||||
|
request<TResponse>(method: string, params: Record<string, unknown>): Promise<TResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端使用 Node 22 内置 `fetch`,固定 POST `/`、JSON-RPC 请求体、连接/读取超时和 1 MiB 响应上限;只抛出 `timeout`、`network`、`protocol`、`unavailable` 四类错误,禁止把 URL、请求头或原始正文放入错误对象。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行定向测试确认通过**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/server exec vitest run apps/api/src/market-overview/tdx/tdx.client.test.ts
|
||||||
|
pnpm --filter @a-share-analysis/server typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交并推送**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/libs/config/src/runtime-config.ts deploy/env/development/api.env.example server/apps/api/src/market-overview/tdx
|
||||||
|
git commit -m "feat(FEAT-MO-001): 增加TDX直连客户端"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: 市场概览 DTO、映射和分模块 API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.mapper.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.dto.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.service.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.service.test.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.controller.ts`
|
||||||
|
- Create: `server/apps/api/src/market-overview/market-overview.module.ts`
|
||||||
|
- Modify: `server/apps/api/src/api.module.ts`
|
||||||
|
- Create: `server/apps/api/test/market-overview.e2e-spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `TdxClient.request<TResponse>()` 和 `ApiRuntimeConfig.tdx`。
|
||||||
|
- Produces: `GET /api/v1/market-overview/{summary,indices,breadth,sectors,funds}`,统一响应元数据和模块数据。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写 DTO 和映射失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('将涨跌字段转换为 A 股红涨绿跌口径并保留百分比', () => {
|
||||||
|
expect(mapIndexQuote({ code: '000001', name: '上证指数', price: 3380.12, change: 28.4 })).toEqual({
|
||||||
|
code: '000001', name: '上证指数', price: 3380.12, changePercent: 0.86, direction: 'up',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少板块数据时返回 partial 而不是抛出整页错误', async () => {
|
||||||
|
const response = await service.getSectors();
|
||||||
|
expect(response.status).toBe('partial');
|
||||||
|
expect(response.warnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行定向测试确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/server exec vitest run apps/api/src/market-overview/tdx/tdx.mapper.test.ts apps/api/src/market-overview/market-overview.service.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现 DTO 和五个服务方法**
|
||||||
|
|
||||||
|
服务公开以下方法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
getSummary(): Promise<MarketOverviewResponse<SummaryData>>;
|
||||||
|
getIndices(): Promise<MarketOverviewResponse<IndexData>>;
|
||||||
|
getBreadth(): Promise<MarketOverviewResponse<BreadthData>>;
|
||||||
|
getSectors(): Promise<MarketOverviewResponse<SectorData>>;
|
||||||
|
getFunds(): Promise<MarketOverviewResponse<FundData>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
每次请求生成 `snapshotId` 和 `asOf`,调用已验证的 TDX 只读方法,映射后只返回页面需要字段。单模块错误映射为 `partial` 或 `unavailable`,不将 TDX 原始错误透传。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 注册控制器并完成 HTTP 测试**
|
||||||
|
|
||||||
|
控制器只注入 `MarketOverviewService`,每个 `@Get` 方法仅调用对应服务方法并声明 Swagger 成功/不可用响应;模块在 `ApiModule.register(config)` 中装配 `TdxClient` 的配置工厂。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/server exec vitest run apps/api/test/market-overview.e2e-spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:五个接口返回 200 或结构化 503,响应不包含 TDX 地址、请求参数或原始载荷。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交并推送**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/apps/api/src/market-overview server/apps/api/src/api.module.ts server/apps/api/test/market-overview.e2e-spec.ts
|
||||||
|
git commit -m "feat(FEAT-MO-001): 增加市场概览分模块接口"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: OpenAPI 契约和前端 API 状态层
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/tools/export-openapi.ts`
|
||||||
|
- Modify: `server/openapi/openapi.json`
|
||||||
|
- Modify: `web/src/api/generated/schema.ts`
|
||||||
|
- Create: `web/src/types/market-overview.ts`
|
||||||
|
- Create: `web/src/api/market-overview/index.ts`
|
||||||
|
- Create: `web/src/api/market-overview/index.test.ts`
|
||||||
|
- Create: `web/src/views/market-overview/useMarketOverview.ts`
|
||||||
|
- Create: `web/src/views/market-overview/useMarketOverview.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 后端五个 OpenAPI 路径和 `web/src/utils/request.ts`。
|
||||||
|
- Produces: `marketOverviewApi`、`useMarketOverview()`、统一模块加载状态和本地分析偏好。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 先写前端 API 和状态层失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('使用独立路径请求指数接口', async () => {
|
||||||
|
const request = vi.fn().mockResolvedValue({ data: { status: 'fresh' } });
|
||||||
|
await createMarketOverviewApi(request).getIndices();
|
||||||
|
expect(request).toHaveBeenCalledWith({ method: 'GET', url: '/api/v1/market-overview/indices' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一个模块失败时其他模块仍保留结果', async () => {
|
||||||
|
const state = await useMarketOverview({ summary: okSummary, indices: failed, breadth: okBreadth, sectors: okSectors, funds: failed });
|
||||||
|
expect(state.summary.status).toBe('ready');
|
||||||
|
expect(state.indices.status).toBe('error');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行定向测试确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/web exec vitest run src/api/market-overview/index.test.ts src/views/market-overview/useMarketOverview.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 生成并校验 OpenAPI 类型**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check:contracts
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:生成的 JSON 和 TypeScript 契约只包含五个市场概览路径,且 `verify-generated-contracts` 通过。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 实现 API 文件和 composable**
|
||||||
|
|
||||||
|
API 文件只调用请求封装:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createMarketOverviewApi(requestClient = request) {
|
||||||
|
return {
|
||||||
|
getSummary: () => requestClient<SummaryResponse>({ method: 'GET', url: '/api/v1/market-overview/summary' }),
|
||||||
|
getIndices: () => requestClient<IndicesResponse>({ method: 'GET', url: '/api/v1/market-overview/indices' }),
|
||||||
|
getBreadth: () => requestClient<BreadthResponse>({ method: 'GET', url: '/api/v1/market-overview/breadth' }),
|
||||||
|
getSectors: () => requestClient<SectorsResponse>({ method: 'GET', url: '/api/v1/market-overview/sectors' }),
|
||||||
|
getFunds: () => requestClient<FundsResponse>({ method: 'GET', url: '/api/v1/market-overview/funds' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useMarketOverview()` 使用 `Promise.allSettled` 并行加载,暴露 `refresh()`、每个模块的 `loading/ready/error` 状态,以及浏览器 `localStorage` 中的分析周期和分析引擎偏好;不得存储行情原始载荷。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行定向测试和类型检查**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/web exec vitest run src/api/market-overview/index.test.ts src/views/market-overview/useMarketOverview.test.ts
|
||||||
|
pnpm --filter @a-share-analysis/web typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交并推送**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/tools/export-openapi.ts server/openapi/openapi.json web/src/api/generated/schema.ts web/src/types/market-overview.ts web/src/api/market-overview web/src/views/market-overview/useMarketOverview.ts web/src/views/market-overview/useMarketOverview.test.ts
|
||||||
|
git commit -m "feat(FEAT-MO-001): 同步市场概览前端契约"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Vue 首屏和交互状态
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/views/market-overview/MarketOverviewPage.vue`
|
||||||
|
- Create: `web/src/views/market-overview/components/MarketConclusion.vue`
|
||||||
|
- Create: `web/src/views/market-overview/components/MarketDrivers.vue`
|
||||||
|
- Create: `web/src/views/market-overview/components/IndexOverview.vue`
|
||||||
|
- Create: `web/src/views/market-overview/components/MarketBreadth.vue`
|
||||||
|
- Create: `web/src/views/market-overview/components/SectorOverview.vue`
|
||||||
|
- Create: `web/src/views/market-overview/components/FundTrend.vue`
|
||||||
|
- Modify: `web/src/app/router.ts`
|
||||||
|
- Modify: `web/src/app/App.vue`
|
||||||
|
- Modify: `web/src/styles/index.scss`
|
||||||
|
- Modify: `web/src/app/App.test.ts`
|
||||||
|
- Modify: `web/tests/smoke.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `useMarketOverview()`、市场概览类型和 `marketOverviewApi`。
|
||||||
|
- Produces: `/` 可访问的市场综合概览首屏,包含数据来源、时间、模块状态、依据入口和分析偏好入口。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 先写页面失败测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('首屏显示市场结论、更新时间和 TDX 来源', async () => {
|
||||||
|
const wrapper = mountMarketOverview({ state: freshState });
|
||||||
|
expect(wrapper.getByRole('heading', { name: '市场综合概览' })).toBeTruthy();
|
||||||
|
expect(wrapper.getByText('数据来源:TDX')).toBeTruthy();
|
||||||
|
expect(wrapper.getByText('更新时间')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TDX 不可用时显示错误状态而不是实时数据', async () => {
|
||||||
|
const wrapper = mountMarketOverview({ state: unavailableState });
|
||||||
|
expect(wrapper.getByText('TDX 数据不可用')).toBeTruthy();
|
||||||
|
expect(wrapper.queryByText('实时行情')).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行前端测试确认失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/web exec vitest run src/app/App.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现首屏和组件状态**
|
||||||
|
|
||||||
|
页面保持结论优先顺序;指数、板块使用按钮/链接样式保留未来详情页入口;加载、空、部分、过期、错误状态均有明确文案和稳定尺寸。分析周期和分析引擎使用 Naive UI 的选择控件,偏好保存在本地,不触发 TDX 请求参数变化。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 接入路由和应用壳**
|
||||||
|
|
||||||
|
`createAppRouter()` 的 `/` 路由渲染 `MarketOverviewPage`;应用壳保留可访问 header、main 和 `NMessageProvider`,不新增无业务意义的导航菜单。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行组件、构建和浏览器冒烟**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @a-share-analysis/web exec vitest run src/app/App.test.ts src/views/market-overview
|
||||||
|
pnpm --filter @a-share-analysis/web build
|
||||||
|
pnpm verify:e2e -- --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:桌面和窄屏无横向溢出,TDX 连接不可用时页面仍可读但明确标记不可用。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交并推送**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/src/views/market-overview web/src/app/router.ts web/src/app/App.vue web/src/styles/index.scss web/src/app/App.test.ts web/tests/smoke.spec.ts
|
||||||
|
git commit -m "feat(FEAT-MO-001): 完成TDX市场概览首屏"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: 真实 TDX 联调、文档追踪和 Review
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/requirements.md`
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/design.md`
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/implementation-plan.md`
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/test-plan.md`
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/development-summary.md`
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/review/code-review.md`
|
||||||
|
- Create: `docs/features/FEAT-MO-001-market-overview/review/standards-review.md`
|
||||||
|
- Modify: `docs/features/FEAT-MO-001-market-overview/manifest.yaml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 已完成的 API、前端页面、脱敏 TDX 基线和测试结果。
|
||||||
|
- Produces: 完整功能档案、开发总结、安全/性能证据、代码 Review 和规范 Review。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 外出 Tailscale 隧道执行真实只读联调**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -S "$HOME/.ssh/tdx-windows-tailnet-control" -O check tdx-windows-tailnet
|
||||||
|
lsof -nP -iTCP:17710 -sTCP:LISTEN
|
||||||
|
pnpm --filter @a-share-analysis/server exec vitest run apps/api/test/market-overview.e2e-spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
记录接口耗时、字段完整性、模块状态和 TDX 错误分类;报告不保存地址、密钥、完整请求或完整响应。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 更新功能档案**
|
||||||
|
|
||||||
|
开发总结必须明确:原始需求、范围变化(由分层混合改为 TDX 单路径)、实际完成、修复缺陷、延期项、关键提交、测试/构建/视觉证据和残余风险。需求、设计、测试计划和 manifest 的状态必须一致。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 执行代码 Review 和规范 Review**
|
||||||
|
|
||||||
|
Review 至少检查:TDX 地址白名单、错误脱敏、模块级降级、请求超时、响应大小、DTO 不透传原始字段、Axios API 边界、中文注释、性能目标、无凭据提交和移动端溢出。问题及处置写入两个 Review 文档。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 完成全量验证和 GitNexus 变更检测**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
npx gitnexus detect-changes --repo /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis --scope all
|
||||||
|
pnpm verify
|
||||||
|
pnpm verify:source
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:GitNexus 只显示市场概览、配置、OpenAPI、前端路由和测试相关变更;任何未预期跨模块影响必须先处置。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 执行治理收尾并推送标签(仅在用户批准发布时)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm governance:finish -- FEAT-MO-001 market-overview
|
||||||
|
git add docs/features/FEAT-MO-001-market-overview
|
||||||
|
git commit -m "docs(FEAT-MO-001): 完成市场概览开发收尾"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
未获发布批准前不创建或推送版本标签;所有提交推送失败时不得标记功能完成。
|
||||||
|
|
||||||
|
## 计划自审
|
||||||
|
|
||||||
|
- 设计中的五个分模块接口均在 Task 3、Task 4、Task 5 有对应实现和测试。
|
||||||
|
- TDX 单一路径、无样本回退、安全、性能、分钟级刷新、错误状态和脱敏报告均有明确任务。
|
||||||
|
- OpenAPI、前端 Axios API 文件、Nest 分层、中文注释和 GitNexus 门禁均有明确文件及命令。
|
||||||
|
- 没有 `TODO`、`TBD` 或未定义的后续函数;TDX 方法名在 Task 1 以实际只读协议验证结果为准并写入类型,避免臆测字段。
|
||||||
|
- MySQL/Redis/RocketMQ/AI/用户体系未被实现任务隐式引入。
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 市场综合概览 TDX 直连设计
|
||||||
|
|
||||||
|
- 功能编号:FEAT-MO-001
|
||||||
|
- 设计版本:0.2
|
||||||
|
- 状态:已获用户确认,待实现计划
|
||||||
|
- 日期:2026-08-18
|
||||||
|
|
||||||
|
## 1. 设计目标
|
||||||
|
|
||||||
|
首页市场概览直接使用 TDX 行情数据,向用户提供结论优先的综合盘面视图。数据链路不保留离线样本 Provider 或运行时数据源切换,TDX 不可用时明确展示不可用状态,不伪造实时行情。
|
||||||
|
|
||||||
|
首屏支持综合、短线和中长线视角,以及规则、AI、混合和对照模式的状态入口;本次 TDX 首屏只提供统一的市场快照和规则事实展示,不在本轮实现付费 AI 结论。
|
||||||
|
|
||||||
|
## 2. 系统边界
|
||||||
|
|
||||||
|
浏览器不得直接连接 TDX。Nest API 在服务端调用本机 TDX 隧道端点,Vue 通过既有 Axios 封装访问 Nest API。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Vue 市场概览
|
||||||
|
-> Axios request 封装
|
||||||
|
-> Nest 市场概览 API
|
||||||
|
-> TDX HTTP 客户端
|
||||||
|
-> 127.0.0.1:17710
|
||||||
|
-> SSH / Tailscale 隧道
|
||||||
|
-> Windows 127.0.0.1:17709
|
||||||
|
-> TDX
|
||||||
|
```
|
||||||
|
|
||||||
|
家庭局域网和外出 Tailscale 只改变 SSH 别名和网络路径,业务服务始终访问本机 `127.0.0.1:17710`。真实地址、用户、密钥和控制套接字只保存在开发机,不进入仓库。
|
||||||
|
|
||||||
|
## 3. 后端模块设计
|
||||||
|
|
||||||
|
后端按市场概览业务模块组织,保持连续可读的职责边界:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server/apps/api/src/market-overview/
|
||||||
|
├── market-overview.module.ts
|
||||||
|
├── market-overview.controller.ts
|
||||||
|
├── market-overview.service.ts
|
||||||
|
├── market-overview.dto.ts
|
||||||
|
└── tdx/
|
||||||
|
├── tdx.client.ts
|
||||||
|
├── tdx.mapper.ts
|
||||||
|
└── tdx.types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- `controller`:提供按业务模块拆分的只读 HTTP 接口。
|
||||||
|
- `service`:组织 TDX 数据、统一快照时间并生成规则事实摘要。
|
||||||
|
- `dto`:定义 HTTP 响应契约,禁止把 TDX 原始字段直接暴露给前端。
|
||||||
|
- `tdx.client`:只负责请求本机 TDX 端点、超时和协议级错误。
|
||||||
|
- `tdx.mapper`:把 TDX 原始字段转换为领域字段和展示口径。
|
||||||
|
- `tdx.types`:描述经过确认的 TDX 原始响应结构。
|
||||||
|
|
||||||
|
首版接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/market-overview/summary
|
||||||
|
GET /api/market-overview/indices
|
||||||
|
GET /api/market-overview/breadth
|
||||||
|
GET /api/market-overview/sectors
|
||||||
|
GET /api/market-overview/funds
|
||||||
|
```
|
||||||
|
|
||||||
|
接口按模块拆分,页面进入时可并行请求;每个接口失败不会使其他模块整体失败。未来详情页可以复用指数和板块的业务边界,不通过新增巨型首页接口解决。
|
||||||
|
|
||||||
|
## 4. 前端模块设计
|
||||||
|
|
||||||
|
```text
|
||||||
|
web/src/
|
||||||
|
├── api/market-overview/index.ts
|
||||||
|
├── views/market-overview/
|
||||||
|
│ ├── MarketOverviewPage.vue
|
||||||
|
│ ├── useMarketOverview.ts
|
||||||
|
│ └── components/
|
||||||
|
└── types/market-overview.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
业务请求只能通过 `web/src/api/market-overview/index.ts` 调用 `web/src/utils/request.ts`。页面组件不直接使用 Axios、不拼接 TDX 地址,也不读取服务端环境变量。
|
||||||
|
|
||||||
|
首屏顺序:市场结论、驱动与风险、核心指数、涨跌分布与市场宽度、成交量与成交额、板块强弱、资金趋势、更新时间和 TDX 连接状态。指数和板块保留进入未来详情页的可识别入口。
|
||||||
|
|
||||||
|
## 5. 响应契约与状态
|
||||||
|
|
||||||
|
每个业务接口采用统一外层结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"snapshotId": "20260818-101500",
|
||||||
|
"asOf": "2026-08-18T10:15:00+08:00",
|
||||||
|
"source": "tdx",
|
||||||
|
"status": "fresh",
|
||||||
|
"data": {},
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 取值为 `fresh`、`delayed`、`partial` 或 `unavailable`。`snapshotId` 和 `asOf` 用于让并行接口在同一次市场快照下可对照;数据日期、更新时间和来源状态必须在页面可见。
|
||||||
|
|
||||||
|
- TDX 单个模块失败:只显示对应模块错误。
|
||||||
|
- TDX 返回部分字段:保留可用字段并标记 `partial`。
|
||||||
|
- TDX 超时或完全不可用:显示连接状态和重试入口,不显示虚假行情。
|
||||||
|
- 后端日志只记录脱敏错误摘要,不保存完整原始行情载荷。
|
||||||
|
|
||||||
|
## 6. 安全策略
|
||||||
|
|
||||||
|
- TDX 地址固定来自服务端配置,禁止由 HTTP 请求参数传入任意 URL,避免 SSRF。
|
||||||
|
- TDX 仅通过本机回环端点访问;Windows TDX 继续只监听 `127.0.0.1:17709`。
|
||||||
|
- API 只读,不提供交易指令、买卖建议、目标价或仓位建议。
|
||||||
|
- SSH、Tailscale、TDX 账号、密钥、密码、本地环境文件和生产地址不提交 Git。
|
||||||
|
- 真实行情只用于只读联调,日志、测试报告和截图不得保存家庭网络信息或完整原始载荷。
|
||||||
|
|
||||||
|
## 7. 性能策略
|
||||||
|
|
||||||
|
- 页面并行请求各业务接口,避免串行阻塞整页。
|
||||||
|
- TDX 客户端设置连接超时和读取超时,并记录请求耗时。
|
||||||
|
- DTO 只返回页面需要字段,不透传完整 TDX 原始响应。
|
||||||
|
- 首版采用分钟级刷新,不承诺秒级实时;暂不启用 Redis 缓存,待数据口径和刷新频率稳定后评估。
|
||||||
|
- 前端使用模块级加载、空、部分数据和错误状态,避免一个模块阻断首屏。
|
||||||
|
|
||||||
|
## 8. 测试与验证
|
||||||
|
|
||||||
|
- TDX 类型转换和展示口径的单元测试。
|
||||||
|
- 每个接口的成功、超时、空数据、部分字段和不可用测试。
|
||||||
|
- Nest API 响应契约和错误状态测试。
|
||||||
|
- Vue API 模块、页面状态和模块级失败降级测试。
|
||||||
|
- 家庭局域网 SSH 路径和外出 Tailscale SSH 路径的端到端只读联调。
|
||||||
|
- 桌面与窄屏视觉验证,确认结论、更新时间和错误状态不重叠。
|
||||||
|
- 开发前和提交前执行 GitNexus 影响分析与变更检测;开发完成执行代码 Review、规范 Review、测试、构建和 `governance:finish`。
|
||||||
|
|
||||||
|
## 9. 明确不在本次实现范围
|
||||||
|
|
||||||
|
- 离线样本 Provider、运行时数据源切换和自动回退。
|
||||||
|
- MySQL、Redis、RocketMQ 的正式行情存储或采集链路。
|
||||||
|
- 付费 AI 请求和 AI 结论生成。
|
||||||
|
- 用户、邀请、会员、订阅、社区、自选股、笔记、计划和交易功能。
|
||||||
|
- 指数或板块详情页的完整实现;本次只保留入口和接口边界。
|
||||||
|
|
||||||
|
## 10. 回退方案
|
||||||
|
|
||||||
|
若 TDX 协议字段无法稳定确认、首屏核心模块无法独立降级或隧道联调影响本地构建,则停止 TDX 直连功能提交,回退到上一个本地标签;不得以样本数据冒充 TDX 实时结果。回退只撤销本功能提交,不删除开发机 SSH、Tailscale 或 TDX 配置。
|
||||||
@@ -10,6 +10,15 @@ export default [
|
|||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
...vue.configs['flat/recommended'],
|
...vue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
// Vue 单文件组件的 script setup 使用 TypeScript,避免 ESLint 退回 Espree。
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,mjs,cjs,ts,tsx,vue}'],
|
files: ['**/*.{js,mjs,cjs,ts,tsx,vue}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createMysqlHealthClient } from '../../../libs/infrastructure/src/mysql-
|
|||||||
import { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client';
|
import { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service';
|
import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service';
|
||||||
|
import { MarketOverviewModule } from './market-overview/market-overview.module';
|
||||||
|
|
||||||
/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */
|
/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */
|
||||||
export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG');
|
export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG');
|
||||||
@@ -19,6 +20,7 @@ export class ApiModule {
|
|||||||
static register(config: ApiRuntimeConfig): DynamicModule {
|
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||||
return {
|
return {
|
||||||
module: ApiModule,
|
module: ApiModule,
|
||||||
|
imports: [MarketOverviewModule.register(config)],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
HealthService,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BreadthResponseDto,
|
||||||
|
FundsResponseDto,
|
||||||
|
IndicesResponseDto,
|
||||||
|
SectorsResponseDto,
|
||||||
|
SummaryResponseDto,
|
||||||
|
} from './market-overview.dto';
|
||||||
|
import { MarketOverviewService } from './market-overview.service';
|
||||||
|
|
||||||
|
@ApiTags('market-overview')
|
||||||
|
@Controller('market-overview')
|
||||||
|
export class MarketOverviewController {
|
||||||
|
constructor(@Inject(MarketOverviewService) private readonly service: MarketOverviewService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
@ApiOkResponse({ type: SummaryResponseDto })
|
||||||
|
getSummary(): Promise<SummaryResponseDto> {
|
||||||
|
return this.service.getSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('indices')
|
||||||
|
@ApiOkResponse({ type: IndicesResponseDto })
|
||||||
|
getIndices(): Promise<IndicesResponseDto> {
|
||||||
|
return this.service.getIndices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('breadth')
|
||||||
|
@ApiOkResponse({ type: BreadthResponseDto })
|
||||||
|
getBreadth(): Promise<BreadthResponseDto> {
|
||||||
|
return this.service.getBreadth();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sectors')
|
||||||
|
@ApiOkResponse({ type: SectorsResponseDto })
|
||||||
|
getSectors(): Promise<SectorsResponseDto> {
|
||||||
|
return this.service.getSectors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('funds')
|
||||||
|
@ApiOkResponse({ type: FundsResponseDto })
|
||||||
|
getFunds(): Promise<FundsResponseDto> {
|
||||||
|
return this.service.getFunds();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
export type QuoteDirection = 'up' | 'down' | 'flat';
|
||||||
|
|
||||||
|
export class MarketOverviewMetaDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
snapshotId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, format: 'date-time' })
|
||||||
|
asOf!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, enum: ['tdx'] })
|
||||||
|
source!: 'tdx';
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
|
||||||
|
status!: MarketOverviewStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
warnings!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IndexQuoteDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
price!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
changePercent!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
||||||
|
direction!: QuoteDirection;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
tradeDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IndicesDataDto {
|
||||||
|
@ApiProperty({ type: [IndexQuoteDto] })
|
||||||
|
items!: IndexQuoteDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SummaryDataDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
verdict!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
marketStatus!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
volumeStatus!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
sentimentStatus!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
drivers!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
risks!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BreadthDataDto {
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
advancing!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
declining!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
|
unchanged!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SectorQuoteDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
changePercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
||||||
|
direction!: QuoteDirection;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
tradeDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SectorDataDto {
|
||||||
|
@ApiProperty({ type: [SectorQuoteDto] })
|
||||||
|
items!: SectorQuoteDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FundDataDto {
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
currentAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
previousAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
changePercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '亿元' })
|
||||||
|
unit!: '亿元';
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, minimum: 1, maximum: 3 })
|
||||||
|
marketCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '20260818' })
|
||||||
|
tradeDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '20260817' })
|
||||||
|
previousTradeDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
||||||
|
@ApiProperty({ type: () => SummaryDataDto, nullable: true })
|
||||||
|
data!: SummaryDataDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IndicesResponseDto extends MarketOverviewMetaDto {
|
||||||
|
@ApiProperty({ type: () => IndicesDataDto, nullable: true })
|
||||||
|
data!: IndicesDataDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BreadthResponseDto extends MarketOverviewMetaDto {
|
||||||
|
@ApiProperty({ type: () => BreadthDataDto, nullable: true })
|
||||||
|
data!: BreadthDataDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SectorsResponseDto extends MarketOverviewMetaDto {
|
||||||
|
@ApiProperty({ type: () => SectorDataDto, nullable: true })
|
||||||
|
data!: SectorDataDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FundsResponseDto extends MarketOverviewMetaDto {
|
||||||
|
@ApiProperty({ type: () => FundDataDto, nullable: true })
|
||||||
|
data!: FundDataDto | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type DynamicModule, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config';
|
||||||
|
import { MarketOverviewController } from './market-overview.controller';
|
||||||
|
import { MarketOverviewService, TDX_CLIENT_PORT } from './market-overview.service';
|
||||||
|
import { createTdxClient } from './tdx/tdx.client';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class MarketOverviewModule {
|
||||||
|
/** 将 API 已校验的 TDX 配置注入业务模块,避免模块内部重新读取环境变量。 */
|
||||||
|
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||||
|
return {
|
||||||
|
module: MarketOverviewModule,
|
||||||
|
controllers: [MarketOverviewController],
|
||||||
|
providers: [
|
||||||
|
MarketOverviewService,
|
||||||
|
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { MarketOverviewService } from './market-overview.service';
|
||||||
|
|
||||||
|
const indexSeries = {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['3350', '3370'],
|
||||||
|
High: ['3385', '3400'],
|
||||||
|
Low: ['3340', '3360'],
|
||||||
|
Close: ['3360', '3380'],
|
||||||
|
Amount: ['100', '120'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const indexResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: Object.fromEntries(
|
||||||
|
['000001.SH', '399001.SZ', '399006.SZ'].map((code) => [code, indexSeries]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadthSnapshot = (up: string, down: string) => ({
|
||||||
|
ErrorId: '0',
|
||||||
|
UpHome: up,
|
||||||
|
DownHome: down,
|
||||||
|
ItemNum: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectorResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: [
|
||||||
|
{ Code: '881319.SH', Name: '半导体' },
|
||||||
|
{ Code: '881355.SH', Name: '软件服务' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectorKlineResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881319.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '105'],
|
||||||
|
High: ['106', '110'],
|
||||||
|
Low: ['99', '104'],
|
||||||
|
Close: ['100', '108'],
|
||||||
|
Amount: ['1000', '1200'],
|
||||||
|
Volume: ['100', '120'],
|
||||||
|
},
|
||||||
|
'881355.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '98'],
|
||||||
|
High: ['101', '99'],
|
||||||
|
Low: ['97', '95'],
|
||||||
|
Close: ['100', '96'],
|
||||||
|
Amount: ['1000', '900'],
|
||||||
|
Volume: ['100', '90'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const turnoverResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: Object.fromEntries(
|
||||||
|
['000001.SH', '399001.SZ', '899050.BJ'].map((code) => [
|
||||||
|
code,
|
||||||
|
{
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '100'],
|
||||||
|
High: ['101', '101'],
|
||||||
|
Low: ['99', '99'],
|
||||||
|
Close: ['100', '100'],
|
||||||
|
Amount: ['100000', '120000'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('市场概览服务', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('读取指数并返回统一快照元数据', async () => {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getIndices();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: { items: expect.any(Array) },
|
||||||
|
});
|
||||||
|
expect(response.snapshotId).toMatch(/^20260818-/);
|
||||||
|
expect(client.request).toHaveBeenCalledWith(
|
||||||
|
'get_market_data',
|
||||||
|
expect.objectContaining({ count: 5 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('核心指数交易日旧于上海当前日期时标记 delayed', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getIndices();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['核心指数交易日期为 20260818,非当前日期']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('3', '2'))
|
||||||
|
.mockResolvedValueOnce(indexResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getBreadth();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: { advancing: 33, declining: 22, unchanged: null },
|
||||||
|
});
|
||||||
|
expect(client.request).toHaveBeenCalledWith(
|
||||||
|
'get_market_snapshot',
|
||||||
|
expect.objectContaining({ stock_code: '000001.SH' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度排除畸形快照并标记部分数据', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
|
.mockResolvedValueOnce({ ErrorId: '0', UpHome: '', DownHome: '3' })
|
||||||
|
.mockRejectedValueOnce(new Error('raw network detail'))
|
||||||
|
.mockResolvedValueOnce(indexResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getBreadth();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
});
|
||||||
|
expect(response.warnings[0]).toContain('深证市场、北证市场');
|
||||||
|
expect(response.warnings.join(' ')).not.toContain('raw network detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度使用核心指数交易日识别 delayed,不把旧快照标为 fresh', async () => {
|
||||||
|
const staleIndexResult = structuredClone(indexResult);
|
||||||
|
staleIndexResult.Value['000001.SH'].Date = ['20260816', '20260817'];
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('3', '2'))
|
||||||
|
.mockResolvedValueOnce(staleIndexResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getBreadth();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['市场宽度交易日期为 20260817,非当前日期']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 8 }),
|
||||||
|
expect.objectContaining({ code: '881355.SH', name: '软件服务', changePercent: -4 }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
|
||||||
|
market: '16',
|
||||||
|
list_type: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一级行业目录为空时返回不可用而不是 fresh 空数据', async () => {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue({ ErrorId: '0', Value: [] }) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ status: 'unavailable', data: null });
|
||||||
|
expect(response.warnings).toEqual(['一级行业目录为空']);
|
||||||
|
expect(client.request).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一级行业强弱结果最多展示前八项', async () => {
|
||||||
|
const definitions = Array.from({ length: 9 }, (_, index) => ({
|
||||||
|
Code: `88100${index}.SH`,
|
||||||
|
Name: `行业${index}`,
|
||||||
|
}));
|
||||||
|
const value = Object.fromEntries(
|
||||||
|
definitions.map(({ Code }, index) => [
|
||||||
|
Code,
|
||||||
|
{
|
||||||
|
...indexSeries,
|
||||||
|
Close: ['100', `${101 + index}`],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ErrorId: '0', Value: definitions })
|
||||||
|
.mockResolvedValueOnce({ ErrorId: '0', Value: value }),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response.status).toBe('fresh');
|
||||||
|
expect(response.data?.items).toHaveLength(8);
|
||||||
|
expect(response.data?.items[0].name).toBe('行业8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一级行业混合交易日时返回 partial,不把不同日期误标 fresh', async () => {
|
||||||
|
const mixedDates = structuredClone(sectorKlineResult);
|
||||||
|
mixedDates.Value['881355.SH'].Date = ['20260818', '20260819'];
|
||||||
|
const client = {
|
||||||
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(mixedDates),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
data: { items: [expect.objectContaining({ code: '881355.SH', tradeDate: '20260819' })] },
|
||||||
|
});
|
||||||
|
expect(response.warnings).toEqual(['部分一级行业未返回有效行情']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一级行业交易日旧于上海当前日期时标记 delayed', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||||
|
const client = {
|
||||||
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['一级行业交易日期为 20260818,非当前日期']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getFunds();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
currentAmount: 36,
|
||||||
|
previousAmount: 30,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: 3,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额缺少一个市场时标记 partial 并展示实际市场数', async () => {
|
||||||
|
const partialResult = structuredClone(turnoverResult);
|
||||||
|
delete partialResult.Value['899050.BJ'];
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(partialResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getFunds();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
data: { currentAmount: 24, previousAmount: 20, marketCount: 2, tradeDate: '20260818' },
|
||||||
|
});
|
||||||
|
expect(response.warnings).toEqual(['部分市场成交额不可用,当前汇总 2 个市场']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额交易日旧于上海当前日期时标记 delayed', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getFunds();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['成交额交易日期为 20260818,非当前日期']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BreadthDataDto,
|
||||||
|
FundDataDto,
|
||||||
|
IndicesDataDto,
|
||||||
|
SectorDataDto,
|
||||||
|
SummaryDataDto,
|
||||||
|
} from './market-overview.dto';
|
||||||
|
import { CORE_INDEXES } from './tdx/tdx.types';
|
||||||
|
import {
|
||||||
|
mapBreadthSnapshots,
|
||||||
|
mapIndexQuotes,
|
||||||
|
mapSectorQuotes,
|
||||||
|
mapTurnoverTrend,
|
||||||
|
} from './tdx/tdx.mapper';
|
||||||
|
import { TdxClientError, type TdxClient } from './tdx/tdx.client';
|
||||||
|
import type {
|
||||||
|
TdxMarketDataResult,
|
||||||
|
TdxMarketSnapshotResult,
|
||||||
|
TdxStockListResult,
|
||||||
|
} from './tdx/tdx.types';
|
||||||
|
import { BREADTH_INDEXES, TURNOVER_INDEXES } from './tdx/tdx.types';
|
||||||
|
|
||||||
|
export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT');
|
||||||
|
|
||||||
|
export type MarketOverviewResponse<TData> = {
|
||||||
|
snapshotId: string;
|
||||||
|
asOf: string;
|
||||||
|
source: 'tdx';
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
data: TData | null;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSnapshotMeta(): Pick<
|
||||||
|
MarketOverviewResponse<never>,
|
||||||
|
'snapshotId' | 'asOf' | 'source'
|
||||||
|
> {
|
||||||
|
const asOf = new Date().toISOString();
|
||||||
|
const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`;
|
||||||
|
return { snapshotId, asOf, source: 'tdx' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 交易日状态统一按上海日历判断;休市日保守标记 delayed,避免把历史值冒充实时。 */
|
||||||
|
function getShanghaiCalendarDate(asOf: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(new Date(asOf));
|
||||||
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${values.year}${values.month}${values.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTradeDateWarning(subject: string, tradeDate: string | undefined, asOf: string) {
|
||||||
|
if (!tradeDate) return `${subject}交易日期无法确认`;
|
||||||
|
return tradeDate === getShanghaiCalendarDate(asOf)
|
||||||
|
? null
|
||||||
|
: `${subject}交易日期为 ${tradeDate},非当前日期`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarketOverviewService {
|
||||||
|
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
|
||||||
|
|
||||||
|
async getIndices(): Promise<MarketOverviewResponse<IndicesDataDto>> {
|
||||||
|
const meta = createSnapshotMeta();
|
||||||
|
try {
|
||||||
|
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: CORE_INDEXES.map((item) => item.code),
|
||||||
|
count: 5,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
});
|
||||||
|
const items = mapIndexQuotes(result, CORE_INDEXES);
|
||||||
|
const tradeDateWarning = createTradeDateWarning('核心指数', items[0]?.tradeDate, meta.asOf);
|
||||||
|
const status =
|
||||||
|
items.length !== CORE_INDEXES.length ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: { items },
|
||||||
|
warnings:
|
||||||
|
status === 'partial'
|
||||||
|
? ['部分核心指数未返回有效数据']
|
||||||
|
: tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [this.toWarning(error, '核心指数')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummary(): Promise<MarketOverviewResponse<SummaryDataDto>> {
|
||||||
|
const indices = await this.getIndices();
|
||||||
|
if (indices.status === 'unavailable' || !indices.data) {
|
||||||
|
return { ...indices, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = indices.data.items
|
||||||
|
.map((item) => item.changePercent)
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const averageChange = changes.length
|
||||||
|
? Math.round((changes.reduce((sum, value) => sum + value, 0) / changes.length) * 100) / 100
|
||||||
|
: null;
|
||||||
|
const marketStatus =
|
||||||
|
averageChange === null ? '指数数据不完整' : averageChange >= 0 ? '指数偏强' : '指数偏弱';
|
||||||
|
const summary: SummaryDataDto = {
|
||||||
|
verdict:
|
||||||
|
averageChange === null
|
||||||
|
? '市场状态待确认'
|
||||||
|
: averageChange >= 0
|
||||||
|
? '市场维持偏强震荡'
|
||||||
|
: '市场维持偏弱震荡',
|
||||||
|
marketStatus,
|
||||||
|
volumeStatus: '成交额趋势见下方',
|
||||||
|
sentimentStatus: '涨跌家数见下方',
|
||||||
|
drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'],
|
||||||
|
risks: ['主力净流入和盘中新闻仍待补充数据源验证'],
|
||||||
|
};
|
||||||
|
return { ...indices, data: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
||||||
|
const meta = createSnapshotMeta();
|
||||||
|
const [results, [referenceResult]] = await Promise.all([
|
||||||
|
Promise.allSettled(
|
||||||
|
BREADTH_INDEXES.map((item) =>
|
||||||
|
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
|
||||||
|
stock_code: item.code,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Promise.allSettled([
|
||||||
|
this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: [CORE_INDEXES[0].code],
|
||||||
|
count: 1,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const validSnapshots = results.flatMap((result) => {
|
||||||
|
if (result.status === 'rejected') return [];
|
||||||
|
return mapBreadthSnapshots([result.value]).validCount === 1 ? [result.value] : [];
|
||||||
|
});
|
||||||
|
const mapped = mapBreadthSnapshots(validSnapshots);
|
||||||
|
const unavailableMarkets = BREADTH_INDEXES.filter((_, index) => {
|
||||||
|
const result = results[index];
|
||||||
|
return (
|
||||||
|
!result ||
|
||||||
|
result.status === 'rejected' ||
|
||||||
|
mapBreadthSnapshots([result.value]).validCount === 0
|
||||||
|
);
|
||||||
|
}).map((item) => item.name);
|
||||||
|
if (!mapped.data) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const referenceTradeDate =
|
||||||
|
referenceResult?.status === 'fulfilled'
|
||||||
|
? mapIndexQuotes(referenceResult.value, [CORE_INDEXES[0]])[0]?.tradeDate
|
||||||
|
: undefined;
|
||||||
|
const tradeDateWarning = createTradeDateWarning('市场宽度', referenceTradeDate, meta.asOf);
|
||||||
|
const status =
|
||||||
|
mapped.validCount !== BREADTH_INDEXES.length
|
||||||
|
? 'partial'
|
||||||
|
: tradeDateWarning
|
||||||
|
? 'delayed'
|
||||||
|
: 'fresh';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: mapped.data,
|
||||||
|
warnings:
|
||||||
|
status === 'partial'
|
||||||
|
? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`]
|
||||||
|
: tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSectors(): Promise<MarketOverviewResponse<SectorDataDto>> {
|
||||||
|
const meta = createSnapshotMeta();
|
||||||
|
try {
|
||||||
|
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
|
||||||
|
market: '16',
|
||||||
|
list_type: 1,
|
||||||
|
});
|
||||||
|
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
|
||||||
|
if (!definitions.length) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: ['一级行业目录为空'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: definitions.map((item) => item.code),
|
||||||
|
count: 2,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
});
|
||||||
|
const mappedItems = mapSectorQuotes(result, definitions);
|
||||||
|
const items = mappedItems.slice(0, 8);
|
||||||
|
const tradeDateWarning = createTradeDateWarning(
|
||||||
|
'一级行业',
|
||||||
|
mappedItems[0]?.tradeDate,
|
||||||
|
meta.asOf,
|
||||||
|
);
|
||||||
|
const status =
|
||||||
|
mappedItems.length === 0
|
||||||
|
? 'unavailable'
|
||||||
|
: mappedItems.length !== definitions.length
|
||||||
|
? 'partial'
|
||||||
|
: tradeDateWarning
|
||||||
|
? 'delayed'
|
||||||
|
: 'fresh';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: items.length ? { items } : null,
|
||||||
|
warnings:
|
||||||
|
status === 'fresh'
|
||||||
|
? []
|
||||||
|
: status === 'partial'
|
||||||
|
? ['部分一级行业未返回有效行情']
|
||||||
|
: status === 'delayed' && tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: ['一级行业行情不可用'],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [this.toWarning(error, '板块行情')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFunds(): Promise<MarketOverviewResponse<FundDataDto>> {
|
||||||
|
const meta = createSnapshotMeta();
|
||||||
|
try {
|
||||||
|
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: TURNOVER_INDEXES.map((item) => item.code),
|
||||||
|
count: 2,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
});
|
||||||
|
const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
||||||
|
if (!mapped.data)
|
||||||
|
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
||||||
|
const tradeDateWarning = createTradeDateWarning('成交额', mapped.data.tradeDate, meta.asOf);
|
||||||
|
const status =
|
||||||
|
mapped.validMarketCount !== TURNOVER_INDEXES.length
|
||||||
|
? 'partial'
|
||||||
|
: tradeDateWarning
|
||||||
|
? 'delayed'
|
||||||
|
: 'fresh';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: mapped.data,
|
||||||
|
warnings:
|
||||||
|
status === 'partial'
|
||||||
|
? [`部分市场成交额不可用,当前汇总 ${mapped.validMarketCount} 个市场`]
|
||||||
|
: tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [this.toWarning(error, '成交额趋势')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toWarning(error: unknown, subject: string): string {
|
||||||
|
if (error instanceof TdxClientError && error.kind === 'timeout') return `${subject}请求超时`;
|
||||||
|
if (error instanceof TdxClientError && error.kind === 'network') return `${subject}网络不可用`;
|
||||||
|
return `${subject}数据不可用`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createTdxClient } from './tdx.client';
|
||||||
|
|
||||||
|
function response(body: unknown, init?: ResponseInit): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TDX 客户端', () => {
|
||||||
|
it('发送固定 JSON 请求并返回业务结果', async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValue(response({ id: 1, result: { ErrorId: '0', Value: {} } }));
|
||||||
|
const client = createTdxClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:17710',
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.request('get_market_data', { stock_list: ['600519.SH'] })).resolves.toEqual(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
'http://127.0.0.1:17710/',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const request = fetchImpl.mock.calls[0]?.[1];
|
||||||
|
expect(JSON.parse(String(request?.body))).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
method: 'get_market_data',
|
||||||
|
params: { stock_list: ['600519.SH'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将 TDX 错误响应转换为脱敏协议错误', async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValue(
|
||||||
|
response({ id: 1, error: { code: 'bad_request', message: '内部连接信息' } }),
|
||||||
|
);
|
||||||
|
const client = createTdxClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:17710',
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstError = client.request('get_market_data', {});
|
||||||
|
await expect(firstError).rejects.toMatchObject({
|
||||||
|
kind: 'protocol',
|
||||||
|
message: 'TDX 返回业务错误',
|
||||||
|
});
|
||||||
|
await expect(firstError).rejects.not.toThrow('内部连接信息');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将 AbortError 转换为超时错误', async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
||||||
|
const client = createTdxClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:17710',
|
||||||
|
timeoutMs: 50,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.request('get_market_data', {})).rejects.toMatchObject({ kind: 'timeout' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { TdxRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
|
||||||
|
|
||||||
|
export type TdxClientErrorKind = 'timeout' | 'network' | 'protocol' | 'unavailable';
|
||||||
|
|
||||||
|
export class TdxClientError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly kind: TdxClientErrorKind,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TdxClientError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdxClient {
|
||||||
|
request<TResponse>(method: string, params: Record<string, unknown>): Promise<TResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdxClientOptions extends TdxRuntimeConfig {
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
maxResponseBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TdxRpcEnvelope<TResponse> {
|
||||||
|
id?: number;
|
||||||
|
result?: TResponse;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBusinessError(value: unknown): boolean {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return 'ErrorId' in record && record.ErrorId !== '0' && record.ErrorId !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 TDX 的 JSON-RPC/业务错误限制在客户端边界内,避免 URL、请求头和原始载荷进入上层异常。
|
||||||
|
*/
|
||||||
|
export function createTdxClient({
|
||||||
|
baseUrl,
|
||||||
|
timeoutMs,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
||||||
|
}: TdxClientOptions): TdxClient {
|
||||||
|
const endpoint = `${baseUrl.replace(/\/$/, '')}/`;
|
||||||
|
let requestId = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async request<TResponse>(method: string, params: Record<string, unknown>): Promise<TResponse> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: ++requestId, method, params }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
if (contentLength && Number(contentLength) > maxResponseBytes) {
|
||||||
|
throw new TdxClientError('protocol', 'TDX 响应体超过安全上限');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (Buffer.byteLength(text, 'utf8') > maxResponseBytes) {
|
||||||
|
throw new TdxClientError('protocol', 'TDX 响应体超过安全上限');
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new TdxClientError('unavailable', 'TDX 服务不可用');
|
||||||
|
|
||||||
|
let envelope: TdxRpcEnvelope<TResponse>;
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(text) as TdxRpcEnvelope<TResponse>;
|
||||||
|
} catch {
|
||||||
|
throw new TdxClientError('protocol', 'TDX 返回格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.error !== undefined || envelope.result === undefined) {
|
||||||
|
throw new TdxClientError('protocol', 'TDX 返回业务错误');
|
||||||
|
}
|
||||||
|
if (hasBusinessError(envelope.result)) {
|
||||||
|
throw new TdxClientError('protocol', 'TDX 返回业务错误');
|
||||||
|
}
|
||||||
|
return envelope.result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TdxClientError) throw error;
|
||||||
|
if (isAbortError(error)) throw new TdxClientError('timeout', 'TDX 请求超时');
|
||||||
|
throw new TdxClientError('network', 'TDX 网络请求失败');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapBreadthSnapshots,
|
||||||
|
mapIndexQuotes,
|
||||||
|
mapSectorQuotes,
|
||||||
|
mapTurnoverTrend,
|
||||||
|
} from './tdx.mapper';
|
||||||
|
|
||||||
|
describe('TDX 行情映射', () => {
|
||||||
|
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
||||||
|
const result = mapIndexQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['3350.00', '3370.00'],
|
||||||
|
High: ['3385.00', '3400.00'],
|
||||||
|
Low: ['3340.00', '3360.00'],
|
||||||
|
Close: ['3360.00', '3380.16'],
|
||||||
|
Amount: ['100', '120'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '000001.SH', name: '上证指数' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: '000001.SH',
|
||||||
|
name: '上证指数',
|
||||||
|
price: 3380.16,
|
||||||
|
changePercent: 0.6,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少前一日收盘价时标记为平盘并保留当前价', () => {
|
||||||
|
const result = mapIndexQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'399006.SZ': {
|
||||||
|
Date: ['20260818'],
|
||||||
|
Open: ['2100.00'],
|
||||||
|
High: ['2110.00'],
|
||||||
|
Low: ['2090.00'],
|
||||||
|
Close: ['2105.00'],
|
||||||
|
Amount: ['100'],
|
||||||
|
Volume: ['1000'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '399006.SZ', name: '创业板指' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['非法公历日期', ['20269999', '20260818']],
|
||||||
|
['重复日期', ['20260818', '20260818']],
|
||||||
|
['倒序日期', ['20260819', '20260818']],
|
||||||
|
])('前一根 K 线为%s时保留指数当前价但不计算涨跌', (_caseName, dates) => {
|
||||||
|
const result = mapIndexQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: dates,
|
||||||
|
Open: ['100', '200'],
|
||||||
|
High: ['100', '200'],
|
||||||
|
Low: ['100', '200'],
|
||||||
|
Close: ['100', '200'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '000001.SH', name: '上证指数' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: '000001.SH',
|
||||||
|
price: 200,
|
||||||
|
changePercent: null,
|
||||||
|
direction: 'flat',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度映射只汇总可用的上涨和下跌家数', () => {
|
||||||
|
expect(
|
||||||
|
mapBreadthSnapshots([
|
||||||
|
{ UpHome: '10', DownHome: '5' },
|
||||||
|
{ UpHome: '2', DownHome: '3' },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
data: { advancing: 12, declining: 8, unchanged: null },
|
||||||
|
validCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度排除字段缺失、非数值和负数的快照', () => {
|
||||||
|
expect(
|
||||||
|
mapBreadthSnapshots([
|
||||||
|
{ UpHome: '10', DownHome: '5' },
|
||||||
|
{ UpHome: '', DownHome: '3' },
|
||||||
|
{ UpHome: 'NaN', DownHome: '2' },
|
||||||
|
{ UpHome: '-1', DownHome: '2' },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
validCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度拒绝小数和十六进制字符串,避免把非计数值纳入汇总', () => {
|
||||||
|
expect(
|
||||||
|
mapBreadthSnapshots([
|
||||||
|
{ UpHome: '1.5', DownHome: '5' },
|
||||||
|
{ UpHome: '0x10', DownHome: '2' },
|
||||||
|
{ UpHome: '1e3', DownHome: '2' },
|
||||||
|
{ UpHome: '9007199254740992', DownHome: '2' },
|
||||||
|
{ UpHome: '10', DownHome: '5' },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
validCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('核心指数混合交易日时只保留最新交易日,避免跨日组合', () => {
|
||||||
|
const result = mapIndexQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['100', '102'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
'399001.SZ': {
|
||||||
|
Date: ['20260818', '20260819'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['100', '101'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '旧日期指数' },
|
||||||
|
{ code: '399001.SZ', name: '最新日期指数' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([expect.objectContaining({ code: '399001.SZ', tradeDate: '20260819' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('核心指数拒绝不存在的公历日期,避免畸形日期抢占最新截面', () => {
|
||||||
|
const series = (dates: string[], close: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: close,
|
||||||
|
High: close,
|
||||||
|
Low: close,
|
||||||
|
Close: close,
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mapIndexQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': series(['20260817', '20260818'], ['100', '102']),
|
||||||
|
'399001.SZ': series(['20269898', '20269999'], ['100', '101']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '有效日期指数' },
|
||||||
|
{ code: '399001.SZ', name: '畸形日期指数' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([expect.objectContaining({ code: '000001.SH', tradeDate: '20260818' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
|
||||||
|
const result = mapSectorQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['100', '102'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
'881002.SH': {
|
||||||
|
Date: ['20260818'],
|
||||||
|
Open: ['1'],
|
||||||
|
High: ['1'],
|
||||||
|
Low: ['1'],
|
||||||
|
Close: ['100'],
|
||||||
|
Amount: ['1'],
|
||||||
|
Volume: ['1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '881001.SH', name: '行业一' },
|
||||||
|
{ code: '881002.SH', name: '行业二' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
code: '881001.SH',
|
||||||
|
name: '行业一',
|
||||||
|
changePercent: 2,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块前一根 K 线日期无效时不进入强弱排序', () => {
|
||||||
|
expect(
|
||||||
|
mapSectorQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881001.SH': {
|
||||||
|
Date: ['20269999', '20260818'],
|
||||||
|
Open: ['100', '200'],
|
||||||
|
High: ['100', '200'],
|
||||||
|
Low: ['100', '200'],
|
||||||
|
Close: ['100', '200'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '881001.SH', name: '畸形日期行业' }],
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块混合交易日时只保留最新交易日,避免把不同日期混排', () => {
|
||||||
|
const series = (dates: string[], close: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: close,
|
||||||
|
High: close,
|
||||||
|
Low: close,
|
||||||
|
Close: close,
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mapSectorQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881001.SH': series(['20260817', '20260818'], ['100', '102']),
|
||||||
|
'881002.SH': series(['20260818', '20260819'], ['100', '101']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '881001.SH', name: '旧日期行业' },
|
||||||
|
{ code: '881002.SH', name: '最新日期行业' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([expect.objectContaining({ code: '881002.SH', tradeDate: '20260819' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
||||||
|
expect(
|
||||||
|
mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['1', '1'],
|
||||||
|
Amount: ['100000', '120000'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '000001.SH', name: '上证市场' }],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
data: {
|
||||||
|
currentAmount: 12,
|
||||||
|
previousAmount: 10,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: 1,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
validMarketCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额只汇总交易日期一致且金额非负的市场', () => {
|
||||||
|
const series = (dates: string[], amounts: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['1', '1'],
|
||||||
|
Amount: amounts,
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
const result = mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
|
||||||
|
'399001.SZ': series(['20260816', '20260817'], ['200000', '240000']),
|
||||||
|
'899050.BJ': series(['20260817', '20260818'], ['10000', '-1']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
validMarketCount: 1,
|
||||||
|
data: { currentAmount: 12, previousAmount: 10, marketCount: 1, tradeDate: '20260818' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额以多数市场的最新交易日为锚,避免首个市场日期异常导致丢数', () => {
|
||||||
|
const series = (dates: string[], amounts: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['1', '1'],
|
||||||
|
Amount: amounts,
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
|
||||||
|
'399001.SZ': series(['20260818', '20260819'], ['200000', '240000']),
|
||||||
|
'899050.BJ': series(['20260818', '20260819'], ['300000', '360000']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
data: {
|
||||||
|
currentAmount: 60,
|
||||||
|
previousAmount: 50,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: 2,
|
||||||
|
tradeDate: '20260819',
|
||||||
|
previousTradeDate: '20260818',
|
||||||
|
},
|
||||||
|
validMarketCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额排除倒序和非法公历日期,不能让异常日期抢占汇总锚点', () => {
|
||||||
|
const series = (dates: string[], amounts: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['1', '1'],
|
||||||
|
Amount: amounts,
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
|
||||||
|
'399001.SZ': series(['20260817', '20260818'], ['200000', '240000']),
|
||||||
|
'899050.BJ': series(['20260820', '20260819'], ['300000', '360000']),
|
||||||
|
'899051.BJ': series(['20269898', '20269999'], ['300000', '360000']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '日期倒序市场' },
|
||||||
|
{ code: '899051.BJ', name: '非法日期市场' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
data: {
|
||||||
|
currentAmount: 36,
|
||||||
|
previousAmount: 30,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: 2,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
validMarketCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额拒绝日期与金额数组错位的市场', () => {
|
||||||
|
expect(
|
||||||
|
mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260816', '20260817', '20260818'],
|
||||||
|
Open: ['1', '1', '1'],
|
||||||
|
High: ['1', '1', '1'],
|
||||||
|
Low: ['1', '1', '1'],
|
||||||
|
Close: ['1', '1', '1'],
|
||||||
|
Amount: ['100000', '120000'],
|
||||||
|
Volume: ['1', '1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '000001.SH', name: '数组错位市场' }],
|
||||||
|
),
|
||||||
|
).toEqual({ data: null, validMarketCount: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type {
|
||||||
|
BreadthDataDto,
|
||||||
|
FundDataDto,
|
||||||
|
IndexQuoteDto,
|
||||||
|
SectorQuoteDto,
|
||||||
|
} from '../market-overview.dto';
|
||||||
|
import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types';
|
||||||
|
|
||||||
|
function toNumber(value: string | undefined): number | null {
|
||||||
|
if (value === undefined || value.trim() === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeNumber(value: string | undefined): number | null {
|
||||||
|
const parsed = toNumber(value);
|
||||||
|
return parsed !== null && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInteger(value: string | undefined): number | null {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized || !/^\d+$/.test(normalized)) return null;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTradeDate(value: string | undefined): string | null {
|
||||||
|
if (!value || !/^\d{8}$/.test(value)) return null;
|
||||||
|
|
||||||
|
const year = Number(value.slice(0, 4));
|
||||||
|
const month = Number(value.slice(4, 6));
|
||||||
|
const day = Number(value.slice(6, 8));
|
||||||
|
const date = new Date(0);
|
||||||
|
date.setUTCHours(0, 0, 0, 0);
|
||||||
|
date.setUTCFullYear(year, month - 1, day);
|
||||||
|
|
||||||
|
return year >= 1000 &&
|
||||||
|
date.getUTCFullYear() === year &&
|
||||||
|
date.getUTCMonth() === month - 1 &&
|
||||||
|
date.getUTCDate() === day
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTradeDate<T extends { tradeDate: string }>(items: readonly T[]): string | null {
|
||||||
|
return items.reduce<string | null>(
|
||||||
|
(latest, item) => (latest === null || item.tradeDate > latest ? item.tradeDate : latest),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDirection(changePercent: number | null): IndexQuoteDto['direction'] {
|
||||||
|
if (changePercent === null || changePercent === 0) return 'flat';
|
||||||
|
return changePercent > 0 ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapIndexQuote(series: TdxKlineSeries, definition: IndexDefinition): IndexQuoteDto | null {
|
||||||
|
const lastIndex = series.Close.length - 1;
|
||||||
|
const price = toNumber(series.Close[lastIndex]);
|
||||||
|
const previousPrice = toNumber(series.Close[lastIndex - 1]);
|
||||||
|
const tradeDate = toTradeDate(series.Date[lastIndex]);
|
||||||
|
if (price === null || !tradeDate) return null;
|
||||||
|
const previousTradeDate = toTradeDate(series.Date[lastIndex - 1]);
|
||||||
|
|
||||||
|
const changePercent =
|
||||||
|
previousPrice === null ||
|
||||||
|
previousPrice === 0 ||
|
||||||
|
previousTradeDate === null ||
|
||||||
|
previousTradeDate >= tradeDate
|
||||||
|
? null
|
||||||
|
: Math.round(((price - previousPrice) / previousPrice) * 100 * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: definition.code,
|
||||||
|
name: definition.name,
|
||||||
|
price,
|
||||||
|
changePercent,
|
||||||
|
direction: mapDirection(changePercent),
|
||||||
|
tradeDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只把已确认字段映射为领域 DTO,缺失的单个指数不阻断其他指数。 */
|
||||||
|
export function mapIndexQuotes(
|
||||||
|
result: TdxMarketDataResult,
|
||||||
|
definitions: readonly IndexDefinition[],
|
||||||
|
): IndexQuoteDto[] {
|
||||||
|
const candidates = definitions.flatMap((definition) => {
|
||||||
|
const series = result.Value[definition.code];
|
||||||
|
if (!series) return [];
|
||||||
|
const quote = mapIndexQuote(series, definition);
|
||||||
|
return quote ? [quote] : [];
|
||||||
|
});
|
||||||
|
const tradeDate = latestTradeDate(candidates);
|
||||||
|
return tradeDate ? candidates.filter((item) => item.tradeDate === tradeDate) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TDX 涨跌家数必须成对有效且非负;无效市场整体排除,禁止把缺失字段折算为零。 */
|
||||||
|
export function mapBreadthSnapshots(snapshots: readonly { UpHome?: string; DownHome?: string }[]): {
|
||||||
|
data: BreadthDataDto | null;
|
||||||
|
validCount: number;
|
||||||
|
} {
|
||||||
|
const validSnapshots = snapshots.flatMap((item) => {
|
||||||
|
const advancing = toNonNegativeInteger(item.UpHome);
|
||||||
|
const declining = toNonNegativeInteger(item.DownHome);
|
||||||
|
return advancing === null || declining === null ? [] : [{ advancing, declining }];
|
||||||
|
});
|
||||||
|
if (!validSnapshots.length) return { data: null, validCount: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
advancing: validSnapshots.reduce((total, item) => total + item.advancing, 0),
|
||||||
|
declining: validSnapshots.reduce((total, item) => total + item.declining, 0),
|
||||||
|
unchanged: null,
|
||||||
|
},
|
||||||
|
validCount: validSnapshots.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一级行业只有在具备相邻两日收盘价时才参与强弱排序,返回对象严格匹配公开 DTO。 */
|
||||||
|
export function mapSectorQuotes(
|
||||||
|
result: TdxMarketDataResult,
|
||||||
|
definitions: readonly IndexDefinition[],
|
||||||
|
): SectorQuoteDto[] {
|
||||||
|
const candidates = definitions.flatMap((definition) => {
|
||||||
|
const series = result.Value[definition.code];
|
||||||
|
if (!series) return [];
|
||||||
|
const quote = mapIndexQuote(series, definition);
|
||||||
|
if (!quote || quote.changePercent === null) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: quote.code,
|
||||||
|
name: quote.name,
|
||||||
|
changePercent: quote.changePercent,
|
||||||
|
direction: quote.direction,
|
||||||
|
tradeDate: quote.tradeDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const tradeDate = latestTradeDate(candidates);
|
||||||
|
return candidates
|
||||||
|
.filter((item) => item.tradeDate === tradeDate)
|
||||||
|
.sort((left, right) => right.changePercent - left.changePercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成交额仅汇总交易日期对齐、金额非负的市场;TDX Amount 从万元转换为亿元。 */
|
||||||
|
export function mapTurnoverTrend(
|
||||||
|
result: TdxMarketDataResult,
|
||||||
|
definitions: readonly IndexDefinition[],
|
||||||
|
): { data: FundDataDto | null; validMarketCount: number } {
|
||||||
|
const candidates = definitions.flatMap((definition) => {
|
||||||
|
const series = result.Value[definition.code];
|
||||||
|
if (!series) return [];
|
||||||
|
if (series.Date.length < 2 || series.Date.length !== series.Amount.length) return [];
|
||||||
|
const current = toNonNegativeNumber(series.Amount.at(-1));
|
||||||
|
const previous = toNonNegativeNumber(series.Amount.at(-2));
|
||||||
|
const tradeDate = toTradeDate(series.Date.at(-1));
|
||||||
|
const previousTradeDate = toTradeDate(series.Date.at(-2));
|
||||||
|
if (
|
||||||
|
current === null ||
|
||||||
|
previous === null ||
|
||||||
|
previous === 0 ||
|
||||||
|
!tradeDate ||
|
||||||
|
!previousTradeDate ||
|
||||||
|
previousTradeDate >= tradeDate
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ current, previous, tradeDate, previousTradeDate }];
|
||||||
|
});
|
||||||
|
if (!candidates.length) return { data: null, validMarketCount: 0 };
|
||||||
|
|
||||||
|
const expected = candidates.reduce((latest, item) =>
|
||||||
|
item.tradeDate > latest.tradeDate ||
|
||||||
|
(item.tradeDate === latest.tradeDate && item.previousTradeDate > latest.previousTradeDate)
|
||||||
|
? item
|
||||||
|
: latest,
|
||||||
|
);
|
||||||
|
const aligned = candidates.filter(
|
||||||
|
(item) =>
|
||||||
|
item.tradeDate === expected.tradeDate &&
|
||||||
|
item.previousTradeDate === expected.previousTradeDate,
|
||||||
|
);
|
||||||
|
const currentAmount = aligned.reduce((total, item) => total + item.current, 0);
|
||||||
|
const previousAmount = aligned.reduce((total, item) => total + item.previous, 0);
|
||||||
|
if (previousAmount === 0) return { data: null, validMarketCount: aligned.length };
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
|
||||||
|
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
|
||||||
|
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: aligned.length,
|
||||||
|
tradeDate: expected.tradeDate,
|
||||||
|
previousTradeDate: expected.previousTradeDate,
|
||||||
|
},
|
||||||
|
validMarketCount: aligned.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export interface TdxKlineSeries {
|
||||||
|
Date: string[];
|
||||||
|
Open: string[];
|
||||||
|
High: string[];
|
||||||
|
Low: string[];
|
||||||
|
Close: string[];
|
||||||
|
Amount: string[];
|
||||||
|
Volume: string[];
|
||||||
|
ForwardFactor?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdxMarketDataResult {
|
||||||
|
ErrorId: string | number;
|
||||||
|
Value: Record<string, TdxKlineSeries>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdxMarketSnapshotResult {
|
||||||
|
ErrorId: string | number;
|
||||||
|
UpHome?: string;
|
||||||
|
DownHome?: string;
|
||||||
|
Inside?: string;
|
||||||
|
Outside?: string;
|
||||||
|
Amount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdxStockListResult {
|
||||||
|
ErrorId: string | number;
|
||||||
|
Value: Array<{ Code: string; Name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexDefinition {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首屏只使用已通过 TDX K 线只读冒烟的三大指数,代码口径统一带市场后缀。 */
|
||||||
|
export const CORE_INDEXES: readonly IndexDefinition[] = [
|
||||||
|
{ code: '000001.SH', name: '上证指数' },
|
||||||
|
{ code: '399001.SZ', name: '深证成指' },
|
||||||
|
{ code: '399006.SZ', name: '创业板指' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** TDX 快照对指数提供上涨/下跌家数,三个市场相加后作为首屏市场宽度口径。 */
|
||||||
|
export const BREADTH_INDEXES: readonly IndexDefinition[] = [
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 首屏资金模块使用交易所指数成交额,避免把成交额趋势误称为主力净流入。 */
|
||||||
|
export const TURNOVER_INDEXES: readonly IndexDefinition[] = [
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { MarketOverviewController } from '../src/market-overview/market-overview.controller';
|
||||||
|
import {
|
||||||
|
MarketOverviewService,
|
||||||
|
TDX_CLIENT_PORT,
|
||||||
|
} from '../src/market-overview/market-overview.service';
|
||||||
|
|
||||||
|
describe('市场概览 HTTP API', () => {
|
||||||
|
let app: INestApplication | undefined;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close();
|
||||||
|
app = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按模块返回指数和 TDX 来源元数据', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi.fn().mockResolvedValue({
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['3350', '3370'],
|
||||||
|
High: ['3385', '3400'],
|
||||||
|
Low: ['3340', '3360'],
|
||||||
|
Close: ['3360', '3380'],
|
||||||
|
Amount: ['100', '120'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
},
|
||||||
|
'399001.SZ': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['10000', '10100'],
|
||||||
|
High: ['10200', '10300'],
|
||||||
|
Low: ['9900', '10050'],
|
||||||
|
Close: ['10000', '10200'],
|
||||||
|
Amount: ['100', '120'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
},
|
||||||
|
'399006.SZ': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['2100', '2110'],
|
||||||
|
High: ['2120', '2130'],
|
||||||
|
Low: ['2090', '2100'],
|
||||||
|
Close: ['2100', '2120'],
|
||||||
|
Amount: ['100', '120'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [MarketOverviewController],
|
||||||
|
providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/market-overview/indices')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: { items: expect.any(Array) },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块接口返回可点击的 TDX 一级行业对象', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: [{ Code: '881319.SH', Name: '半导体' }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881319.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '101'],
|
||||||
|
High: ['102', '103'],
|
||||||
|
Low: ['99', '100'],
|
||||||
|
Close: ['100', '102'],
|
||||||
|
Amount: ['1000', '1200'],
|
||||||
|
Volume: ['100', '120'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [MarketOverviewController],
|
||||||
|
providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/market-overview/sectors')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
items: [expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 2 })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,8 @@ describe('进程级运行配置', () => {
|
|||||||
LOG_LEVEL: 'debug',
|
LOG_LEVEL: 'debug',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
...baseRedisEnv,
|
...baseRedisEnv,
|
||||||
...disabledMysqlEnv,
|
...disabledMysqlEnv,
|
||||||
...disabledAiEnv,
|
...disabledAiEnv,
|
||||||
@@ -129,6 +131,8 @@ describe('进程级运行配置', () => {
|
|||||||
LOG_LEVEL: 'debug',
|
LOG_LEVEL: 'debug',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
...baseRedisEnv,
|
...baseRedisEnv,
|
||||||
...enabledMysql,
|
...enabledMysql,
|
||||||
...disabledAiEnv,
|
...disabledAiEnv,
|
||||||
@@ -138,12 +142,38 @@ describe('进程级运行配置', () => {
|
|||||||
expect(() => loadApiConfig({ ...apiEnv, MYSQL_TIMEZONE: 'SYSTEM' })).toThrow(/MYSQL_TIMEZONE/);
|
expect(() => loadApiConfig({ ...apiEnv, MYSQL_TIMEZONE: 'SYSTEM' })).toThrow(/MYSQL_TIMEZONE/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('TDX 只允许本机回环地址和固定端口', () => {
|
||||||
|
const apiEnv = {
|
||||||
|
APP_ENV: 'development',
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
API_HOST: '127.0.0.1',
|
||||||
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
|
...baseRedisEnv,
|
||||||
|
...disabledMysqlEnv,
|
||||||
|
...disabledAiEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => loadApiConfig({ ...apiEnv, TDX_BASE_URL: 'http://192.168.28.106:17709' })).toThrow(
|
||||||
|
/TDX_BASE_URL 必须指向本机回环地址/,
|
||||||
|
);
|
||||||
|
expect(() => loadApiConfig({ ...apiEnv, TDX_BASE_URL: 'https://127.0.0.1:17710' })).toThrow(
|
||||||
|
/TDX_BASE_URL 必须使用 http 协议/,
|
||||||
|
);
|
||||||
|
expect(() => loadApiConfig({ ...apiEnv, TDX_BASE_URL: 'http://127.0.0.1:17711' })).toThrow(
|
||||||
|
/TDX_BASE_URL 必须使用 17710 端口/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('日志摘要不包含任何连接密码、密钥或未知字段', () => {
|
it('日志摘要不包含任何连接密码、密钥或未知字段', () => {
|
||||||
const config = loadApiConfig({
|
const config = loadApiConfig({
|
||||||
APP_ENV: 'development',
|
APP_ENV: 'development',
|
||||||
LOG_LEVEL: 'debug',
|
LOG_LEVEL: 'debug',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
...baseRedisEnv,
|
...baseRedisEnv,
|
||||||
...disabledMysqlEnv,
|
...disabledMysqlEnv,
|
||||||
...disabledAiEnv,
|
...disabledAiEnv,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export interface AiRuntimeConfig {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** API 只允许通过本机 SSH/Tailscale 隧道访问 TDX,禁止把家庭地址暴露给业务请求。 */
|
||||||
|
export interface TdxRuntimeConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
||||||
export interface RocketMqRuntimeConfig {
|
export interface RocketMqRuntimeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -54,6 +60,7 @@ interface CommonRuntimeConfig {
|
|||||||
export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
||||||
processName: 'api';
|
processName: 'api';
|
||||||
api: Readonly<{ host: string; port: number }>;
|
api: Readonly<{ host: string; port: number }>;
|
||||||
|
tdx: Readonly<TdxRuntimeConfig>;
|
||||||
mysql: MysqlRuntimeConfig;
|
mysql: MysqlRuntimeConfig;
|
||||||
ai: AiRuntimeConfig;
|
ai: AiRuntimeConfig;
|
||||||
}
|
}
|
||||||
@@ -196,6 +203,30 @@ function loadAiConfig(env: EnvironmentSource): AiRuntimeConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadTdxConfig(env: EnvironmentSource): TdxRuntimeConfig {
|
||||||
|
const baseUrl = requireText(env, 'TDX_BASE_URL').replace(/\/$/, '');
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(baseUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('TDX_BASE_URL 必须为有效 URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:') throw new Error('TDX_BASE_URL 必须使用 http 协议');
|
||||||
|
if (parsed.hostname !== '127.0.0.1') {
|
||||||
|
throw new Error('TDX_BASE_URL 必须指向本机回环地址');
|
||||||
|
}
|
||||||
|
if (parsed.port !== '17710') throw new Error('TDX_BASE_URL 必须使用 17710 端口');
|
||||||
|
if (parsed.pathname !== '' && parsed.pathname !== '/') {
|
||||||
|
throw new Error('TDX_BASE_URL 不得包含额外路径');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: parsed.toString().replace(/\/$/, ''),
|
||||||
|
timeoutMs: parseInteger(env, 'TDX_TIMEOUT_MS', { min: 100, max: 30_000 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
||||||
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
||||||
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
||||||
@@ -225,6 +256,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig {
|
|||||||
host: requireText(env, 'API_HOST'),
|
host: requireText(env, 'API_HOST'),
|
||||||
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
||||||
},
|
},
|
||||||
|
tdx: loadTdxConfig(env),
|
||||||
redis: loadRedisConfig(env),
|
redis: loadRedisConfig(env),
|
||||||
mysql: loadMysqlConfig(env),
|
mysql: loadMysqlConfig(env),
|
||||||
ai: loadAiConfig(env),
|
ai: loadAiConfig(env),
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
{
|
{
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"BreadthDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"advancing": { "type": "number" },
|
||||||
|
"declining": { "type": "number" },
|
||||||
|
"unchanged": { "nullable": true, "type": "number" }
|
||||||
|
},
|
||||||
|
"required": ["advancing", "declining", "unchanged"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"BreadthResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/BreadthDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"DependencyHealthDto": {
|
"DependencyHealthDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": { "type": "string" },
|
"detail": { "type": "string" },
|
||||||
@@ -12,6 +40,46 @@
|
|||||||
"required": ["status", "detail"],
|
"required": ["status", "detail"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FundDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"changePercent": { "type": "number" },
|
||||||
|
"currentAmount": { "type": "number" },
|
||||||
|
"marketCount": { "maximum": 3, "minimum": 1, "type": "number" },
|
||||||
|
"previousAmount": { "type": "number" },
|
||||||
|
"previousTradeDate": { "example": "20260817", "type": "string" },
|
||||||
|
"tradeDate": { "example": "20260818", "type": "string" },
|
||||||
|
"unit": { "example": "亿元", "type": "string" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"currentAmount",
|
||||||
|
"previousAmount",
|
||||||
|
"changePercent",
|
||||||
|
"unit",
|
||||||
|
"marketCount",
|
||||||
|
"tradeDate",
|
||||||
|
"previousTradeDate"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FundsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/FundDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"HealthDependenciesDto": {
|
"HealthDependenciesDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"mysql": { "$ref": "#/components/schemas/DependencyHealthDto" },
|
"mysql": { "$ref": "#/components/schemas/DependencyHealthDto" },
|
||||||
@@ -32,6 +100,125 @@
|
|||||||
},
|
},
|
||||||
"required": ["status", "process", "checkedAt", "dependencies"],
|
"required": ["status", "process", "checkedAt", "dependencies"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
},
|
||||||
|
"IndexQuoteDto": {
|
||||||
|
"properties": {
|
||||||
|
"changePercent": { "nullable": true, "type": "number" },
|
||||||
|
"code": { "type": "string" },
|
||||||
|
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"price": { "type": "number" },
|
||||||
|
"tradeDate": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["code", "name", "price", "direction", "tradeDate"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"IndicesDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": { "$ref": "#/components/schemas/IndexQuoteDto" },
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"IndicesResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/IndicesDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SectorDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": { "$ref": "#/components/schemas/SectorQuoteDto" },
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SectorQuoteDto": {
|
||||||
|
"properties": {
|
||||||
|
"changePercent": { "type": "number" },
|
||||||
|
"code": { "type": "string" },
|
||||||
|
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"tradeDate": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["code", "name", "changePercent", "direction", "tradeDate"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SectorsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/SectorDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SummaryDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"drivers": { "items": { "type": "string" }, "type": "array" },
|
||||||
|
"marketStatus": { "type": "string" },
|
||||||
|
"risks": { "items": { "type": "string" }, "type": "array" },
|
||||||
|
"sentimentStatus": { "type": "string" },
|
||||||
|
"verdict": { "type": "string" },
|
||||||
|
"volumeStatus": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"verdict",
|
||||||
|
"marketStatus",
|
||||||
|
"volumeStatus",
|
||||||
|
"sentimentStatus",
|
||||||
|
"drivers",
|
||||||
|
"risks"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SummaryResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/SummaryDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -67,6 +254,91 @@
|
|||||||
},
|
},
|
||||||
"tags": ["health"]
|
"tags": ["health"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/breadth": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getBreadth",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/BreadthResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/funds": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getFunds",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/FundsResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/indices": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getIndices",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/IndicesResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/sectors": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getSectors",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SectorsResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/summary": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getSummary",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SummaryResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": [],
|
"servers": [],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('NestJS 三进程入口', () => {
|
|||||||
appEnv: 'development',
|
appEnv: 'development',
|
||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
||||||
|
tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }),
|
||||||
redis: redisConfig,
|
redis: redisConfig,
|
||||||
mysql: mysqlConfig,
|
mysql: mysqlConfig,
|
||||||
ai: aiConfig,
|
ai: aiConfig,
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ import path from 'node:path';
|
|||||||
import { format } from 'prettier';
|
import { format } from 'prettier';
|
||||||
|
|
||||||
import { HealthController } from '../apps/api/src/health.controller';
|
import { HealthController } from '../apps/api/src/health.controller';
|
||||||
|
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
|
||||||
import {
|
import {
|
||||||
HealthService,
|
HealthService,
|
||||||
MYSQL_HEALTH_PORT,
|
MYSQL_HEALTH_PORT,
|
||||||
REDIS_HEALTH_PORT,
|
REDIS_HEALTH_PORT,
|
||||||
} from '../apps/api/src/health.service';
|
} from '../apps/api/src/health.service';
|
||||||
|
import {
|
||||||
|
MarketOverviewService,
|
||||||
|
TDX_CLIENT_PORT,
|
||||||
|
} from '../apps/api/src/market-overview/market-overview.service';
|
||||||
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController, MarketOverviewController],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
HealthService,
|
||||||
|
MarketOverviewService,
|
||||||
|
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
|
||||||
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
||||||
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export async function runTestApi() {
|
|||||||
LOG_LEVEL: 'error',
|
LOG_LEVEL: 'error',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
// API 配置要求始终声明本机 TDX 边界;E2E 不依赖 TDX 可达性,页面会验证不可用状态。
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
REDIS_HOST: '127.0.0.1',
|
REDIS_HOST: '127.0.0.1',
|
||||||
REDIS_PORT: '6379',
|
REDIS_PORT: '6379',
|
||||||
REDIS_USERNAME: '',
|
REDIS_USERNAME: '',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
@@ -283,21 +291,17 @@ 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,
|
||||||
mysql_client_version: fields.mysqlClientVersion,
|
|
||||||
mysqldump_client_version: fields.mysqldumpClientVersion,
|
|
||||||
mysql_server_version: fields.serverVersion,
|
|
||||||
charset: REQUIRED_CHARSET,
|
charset: REQUIRED_CHARSET,
|
||||||
collation: REQUIRED_COLLATION,
|
collation: REQUIRED_COLLATION,
|
||||||
generated_at: fields.generatedAt,
|
generated_at: fields.generatedAt,
|
||||||
generator: `schema-baseline/${TOOL_VERSION}`,
|
generator: `schema-baseline/${fields.formatVersion === 3 ? TOOL_VERSION : fields.formatVersion === 2 ? PREVIOUS_TOOL_VERSION : LEGACY_TOOL_VERSION}`,
|
||||||
reason: fields.reason ?? 'none',
|
reason: fields.reason ?? 'none',
|
||||||
schema_sha256: fields.schemaSha256 ?? 'not_applicable',
|
schema_sha256: fields.schemaSha256 ?? 'not_applicable',
|
||||||
objects: {
|
objects: {
|
||||||
@@ -308,20 +312,49 @@ function buildManifest(fields) {
|
|||||||
events: fields.captured,
|
events: fields.captured,
|
||||||
},
|
},
|
||||||
object_counts: fields.objectCounts,
|
object_counts: fields.objectCounts,
|
||||||
},
|
};
|
||||||
null,
|
if (fields.formatVersion === 3) {
|
||||||
2,
|
common.exporter_name = fields.exporterName;
|
||||||
)}\n`;
|
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) {
|
||||||
assertMysql57Metadata(metadata);
|
if (metadata.formatVersion !== 3) {
|
||||||
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
throw new Error('Node Schema 基线必须使用 format v3');
|
||||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
}
|
||||||
|
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 normalizedSql = normalizeSchemaSql(sql);
|
||||||
const objectCounts = countSchemaObjects(normalizedSql);
|
const derivedCounts = countSchemaObjects(normalizedSql);
|
||||||
// mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。
|
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)) {
|
if (Object.values(objectCounts).every((count) => count === 0)) {
|
||||||
return buildCapturedEmptyArtifacts(metadata);
|
return buildCapturedEmptyArtifacts(metadata);
|
||||||
}
|
}
|
||||||
@@ -343,8 +376,54 @@ export function buildCapturedArtifacts(metadata, sql) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
assertMysql57Metadata(metadata);
|
||||||
|
if (!VERSION_PATTERN.test(metadata.applicationVersion ?? '')) {
|
||||||
|
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||||
|
}
|
||||||
|
const normalizedSql = normalizeSchemaSql(sql);
|
||||||
|
const derivedCounts = countSchemaObjects(normalizedSql);
|
||||||
|
// mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。
|
||||||
|
if (Object.values(derivedCounts).every((count) => count === 0)) {
|
||||||
|
return buildCapturedEmptyArtifacts(metadata);
|
||||||
|
}
|
||||||
|
const schemaSha256 = sha256(normalizedSql);
|
||||||
|
const manifest = buildManifest({
|
||||||
|
...metadata,
|
||||||
|
formatVersion: metadata.formatVersion ?? PREVIOUS_BASELINE_FORMAT_VERSION,
|
||||||
|
status: 'captured',
|
||||||
|
captured: true,
|
||||||
|
objectCounts: derivedCounts,
|
||||||
|
schemaSha256,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
status: 'captured',
|
||||||
|
schemaSha256,
|
||||||
|
files: Object.freeze({
|
||||||
|
'manifest.yaml': manifest,
|
||||||
|
'schema.sql': normalizedSql,
|
||||||
|
'checksum.sha256': `${schemaSha256} schema.sql\n`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCapturedEmptyArtifacts(metadata) {
|
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,23 +631,41 @@ 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);
|
||||||
|
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
|
||||||
|
assertNodeMetadata({
|
||||||
|
formatVersion: 3,
|
||||||
|
exporterName: manifest.exporter_name,
|
||||||
|
exporterVersion: manifest.exporter_version,
|
||||||
|
driverName: manifest.database_driver,
|
||||||
|
driverVersion: manifest.database_driver_version,
|
||||||
|
serverVersion: manifest.mysql_server_version,
|
||||||
|
databaseCharset: manifest.charset,
|
||||||
|
databaseCollation: manifest.collation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
assertMysql57Metadata({
|
assertMysql57Metadata({
|
||||||
mysqlClientVersion: manifest.mysql_client_version,
|
mysqlClientVersion: manifest.mysql_client_version,
|
||||||
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
||||||
@@ -530,6 +673,7 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
databaseCharset: manifest.charset,
|
databaseCharset: manifest.charset,
|
||||||
databaseCollation: manifest.collation,
|
databaseCollation: manifest.collation,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const expectedEmptyChecksum = sha256('');
|
const expectedEmptyChecksum = sha256('');
|
||||||
if (
|
if (
|
||||||
manifest.schema_sha256 !== expectedEmptyChecksum ||
|
manifest.schema_sha256 !== expectedEmptyChecksum ||
|
||||||
@@ -546,6 +690,18 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertExactFileSet(targetDirectory, CAPTURED_FILES);
|
assertExactFileSet(targetDirectory, CAPTURED_FILES);
|
||||||
|
if (manifest.format_version === BASELINE_FORMAT_VERSION) {
|
||||||
|
assertNodeMetadata({
|
||||||
|
formatVersion: 3,
|
||||||
|
exporterName: manifest.exporter_name,
|
||||||
|
exporterVersion: manifest.exporter_version,
|
||||||
|
driverName: manifest.database_driver,
|
||||||
|
driverVersion: manifest.database_driver_version,
|
||||||
|
serverVersion: manifest.mysql_server_version,
|
||||||
|
databaseCharset: manifest.charset,
|
||||||
|
databaseCollation: manifest.collation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
assertMysql57Metadata({
|
assertMysql57Metadata({
|
||||||
mysqlClientVersion: manifest.mysql_client_version,
|
mysqlClientVersion: manifest.mysql_client_version,
|
||||||
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
mysqldumpClientVersion: manifest.mysqldump_client_version,
|
||||||
@@ -553,6 +709,7 @@ export function verifyBaselineDirectory(targetDirectory, expectedRelease, expect
|
|||||||
databaseCharset: manifest.charset,
|
databaseCharset: manifest.charset,
|
||||||
databaseCollation: manifest.collation,
|
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,
|
||||||
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
|
formatVersion: BASELINE_FORMAT_VERSION,
|
||||||
}
|
exporterName: MYSQL2_EXPORTER_METADATA.name,
|
||||||
|
exporterVersion: MYSQL2_EXPORTER_METADATA.version,
|
||||||
const executeFile = options.executeFile ?? execFileSync;
|
driverName: MYSQL2_EXPORTER_METADATA.driverName,
|
||||||
const childEnvironment = { ...process.env, MYSQL_PWD: config.MYSQL_PASSWORD ?? '' };
|
driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion,
|
||||||
const executionOptions = {
|
serverVersion: 'not_enabled',
|
||||||
cwd: options.repositoryRoot,
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: childEnvironment,
|
|
||||||
};
|
|
||||||
const mysqlClientVersion = extractClientVersion(
|
|
||||||
executeFile('mysql', ['--version'], executionOptions),
|
|
||||||
);
|
|
||||||
const mysqldumpClientVersion = extractClientVersion(
|
|
||||||
executeFile('mysqldump', ['--version'], executionOptions),
|
|
||||||
);
|
|
||||||
assertMysql57Metadata({
|
|
||||||
mysqlClientVersion,
|
|
||||||
mysqldumpClientVersion,
|
|
||||||
serverVersion: '5.7.0-pending-server-check',
|
|
||||||
databaseCharset: REQUIRED_CHARSET,
|
databaseCharset: REQUIRED_CHARSET,
|
||||||
databaseCollation: REQUIRED_COLLATION,
|
databaseCollation: REQUIRED_COLLATION,
|
||||||
});
|
});
|
||||||
const serverMetadata = String(
|
persistBaselineArtifacts(targetDirectory, artifacts, options.fileOperations);
|
||||||
executeFile('mysql', buildMysqlMetadataArguments(config), executionOptions),
|
return verifyBaselineDirectory(targetDirectory, options.applicationVersion);
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
.split('\t');
|
|
||||||
if (serverMetadata.length !== 3) {
|
|
||||||
throw new Error('无法从固定开发库读取 MySQL 版本、字符集和排序规则');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exported = await (options.exportSchema ?? exportMysql57Schema)({ config });
|
||||||
|
const exportedMetadata = exported.metadata ?? {};
|
||||||
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-'));
|
||||||
}
|
}
|
||||||
@@ -300,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');
|
||||||
@@ -314,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(
|
||||||
@@ -323,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',
|
||||||
@@ -337,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(
|
||||||
@@ -353,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';
|
|
||||||
}
|
|
||||||
return 'CREATE TABLE `sample` (`id` int);\n';
|
|
||||||
},
|
},
|
||||||
|
rawSql: '',
|
||||||
|
objectCounts: { tables: 0, views: 0, triggers: 0, routines: 0, events: 0 },
|
||||||
}),
|
}),
|
||||||
/mysql 客户端必须为 5\.7/,
|
});
|
||||||
);
|
assert.equal(result.status, 'captured_empty');
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
||||||
@@ -387,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',
|
||||||
@@ -451,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(
|
||||||
@@ -461,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');
|
||||||
@@ -488,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');
|
||||||
|
|||||||
@@ -20,15 +20,134 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
'/api/v1/market-overview/breadth': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['MarketOverviewController_getBreadth'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
'/api/v1/market-overview/funds': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['MarketOverviewController_getFunds'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
'/api/v1/market-overview/indices': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['MarketOverviewController_getIndices'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
'/api/v1/market-overview/sectors': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['MarketOverviewController_getSectors'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
'/api/v1/market-overview/summary': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['MarketOverviewController_getSummary'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
BreadthDataDto: {
|
||||||
|
advancing: number;
|
||||||
|
declining: number;
|
||||||
|
unchanged: number | null;
|
||||||
|
};
|
||||||
|
BreadthResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['BreadthDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'tdx';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
DependencyHealthDto: {
|
DependencyHealthDto: {
|
||||||
detail: string;
|
detail: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: 'healthy' | 'unavailable' | 'disabled';
|
status: 'healthy' | 'unavailable' | 'disabled';
|
||||||
};
|
};
|
||||||
|
FundDataDto: {
|
||||||
|
changePercent: number;
|
||||||
|
currentAmount: number;
|
||||||
|
marketCount: number;
|
||||||
|
previousAmount: number;
|
||||||
|
/** @example 20260817 */
|
||||||
|
previousTradeDate: string;
|
||||||
|
/** @example 20260818 */
|
||||||
|
tradeDate: string;
|
||||||
|
/** @example 亿元 */
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
FundsResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['FundDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'tdx';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
HealthDependenciesDto: {
|
HealthDependenciesDto: {
|
||||||
mysql: components['schemas']['DependencyHealthDto'];
|
mysql: components['schemas']['DependencyHealthDto'];
|
||||||
redis: components['schemas']['DependencyHealthDto'];
|
redis: components['schemas']['DependencyHealthDto'];
|
||||||
@@ -43,6 +162,70 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: 'healthy' | 'unavailable';
|
status: 'healthy' | 'unavailable';
|
||||||
};
|
};
|
||||||
|
IndexQuoteDto: {
|
||||||
|
changePercent?: number | null;
|
||||||
|
code: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
direction: 'up' | 'down' | 'flat';
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
tradeDate: string;
|
||||||
|
};
|
||||||
|
IndicesDataDto: {
|
||||||
|
items: components['schemas']['IndexQuoteDto'][];
|
||||||
|
};
|
||||||
|
IndicesResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['IndicesDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'tdx';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
SectorDataDto: {
|
||||||
|
items: components['schemas']['SectorQuoteDto'][];
|
||||||
|
};
|
||||||
|
SectorQuoteDto: {
|
||||||
|
changePercent: number;
|
||||||
|
code: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
direction: 'up' | 'down' | 'flat';
|
||||||
|
name: string;
|
||||||
|
tradeDate: string;
|
||||||
|
};
|
||||||
|
SectorsResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['SectorDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'tdx';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
SummaryDataDto: {
|
||||||
|
drivers: string[];
|
||||||
|
marketStatus: string;
|
||||||
|
risks: string[];
|
||||||
|
sentimentStatus: string;
|
||||||
|
verdict: string;
|
||||||
|
volumeStatus: string;
|
||||||
|
};
|
||||||
|
SummaryResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['SummaryDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'tdx';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@@ -79,4 +262,99 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
MarketOverviewController_getBreadth: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['BreadthResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
MarketOverviewController_getFunds: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['FundsResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
MarketOverviewController_getIndices: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['IndicesResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
MarketOverviewController_getSectors: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['SectorsResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
MarketOverviewController_getSummary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['SummaryResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { RequestClient } from '@/utils/request';
|
||||||
|
import { createMarketOverviewApi } from './index';
|
||||||
|
|
||||||
|
describe('市场概览业务 API', () => {
|
||||||
|
it('使用独立版本化路径请求五个模块', async () => {
|
||||||
|
const requestMock = vi.fn((config: Parameters<RequestClient>[0]) => {
|
||||||
|
void config;
|
||||||
|
return Promise.resolve({});
|
||||||
|
});
|
||||||
|
const requestClient = requestMock as unknown as RequestClient;
|
||||||
|
const api = createMarketOverviewApi(requestClient);
|
||||||
|
|
||||||
|
await api.getSummary();
|
||||||
|
await api.getIndices();
|
||||||
|
await api.getBreadth();
|
||||||
|
await api.getSectors();
|
||||||
|
await api.getFunds();
|
||||||
|
|
||||||
|
expect(requestMock.mock.calls.map(([config]) => config.url)).toEqual([
|
||||||
|
'/api/v1/market-overview/summary',
|
||||||
|
'/api/v1/market-overview/indices',
|
||||||
|
'/api/v1/market-overview/breadth',
|
||||||
|
'/api/v1/market-overview/sectors',
|
||||||
|
'/api/v1/market-overview/funds',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import request, { type RequestClient } from '@/utils/request';
|
||||||
|
import type {
|
||||||
|
BreadthResponse,
|
||||||
|
FundsResponse,
|
||||||
|
IndicesResponse,
|
||||||
|
SectorsResponse,
|
||||||
|
SummaryResponse,
|
||||||
|
} from '@/types/market-overview';
|
||||||
|
|
||||||
|
export function createMarketOverviewApi(requestClient: RequestClient = request) {
|
||||||
|
return {
|
||||||
|
getSummary: () =>
|
||||||
|
requestClient<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }),
|
||||||
|
getIndices: () =>
|
||||||
|
requestClient<IndicesResponse>({ method: 'get', url: '/api/v1/market-overview/indices' }),
|
||||||
|
getBreadth: () =>
|
||||||
|
requestClient<BreadthResponse>({ method: 'get', url: '/api/v1/market-overview/breadth' }),
|
||||||
|
getSectors: () =>
|
||||||
|
requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
|
||||||
|
getFunds: () =>
|
||||||
|
requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketOverviewApi = createMarketOverviewApi();
|
||||||
|
|
||||||
|
export default marketOverviewApi;
|
||||||
+23
-2
@@ -1,11 +1,32 @@
|
|||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { createMemoryHistory } from 'vue-router';
|
import { createMemoryHistory } from 'vue-router';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import { createAppRouter } from './router';
|
import { createAppRouter } from './router';
|
||||||
|
|
||||||
|
// 应用外壳测试只验证导航和主区域,不应因页面挂载触发真实 Axios 请求。
|
||||||
|
vi.mock('@/api/market-overview', () => {
|
||||||
|
const response = {
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
snapshotId: 'test',
|
||||||
|
asOf: '2026-08-18T00:00:00.000Z',
|
||||||
|
warnings: ['测试环境未连接 TDX'],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
getSummary: vi.fn().mockResolvedValue(response),
|
||||||
|
getIndices: vi.fn().mockResolvedValue(response),
|
||||||
|
getBreadth: vi.fn().mockResolvedValue(response),
|
||||||
|
getSectors: vi.fn().mockResolvedValue(response),
|
||||||
|
getFunds: vi.fn().mockResolvedValue(response),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('应用外壳', () => {
|
describe('应用外壳', () => {
|
||||||
it('提供可访问的应用标题和主内容区域', async () => {
|
it('提供可访问的应用标题和主内容区域', async () => {
|
||||||
const router = createAppRouter(createMemoryHistory());
|
const router = createAppRouter(createMemoryHistory());
|
||||||
@@ -21,6 +42,6 @@ describe('应用外壳', () => {
|
|||||||
expect(wrapper.get('header').attributes('aria-label')).toBe('应用导航');
|
expect(wrapper.get('header').attributes('aria-label')).toBe('应用导航');
|
||||||
expect(wrapper.get('main').attributes('id')).toBe('main-content');
|
expect(wrapper.get('main').attributes('id')).toBe('main-content');
|
||||||
expect(wrapper.text()).toContain('A 股市场分析工具');
|
expect(wrapper.text()).toContain('A 股市场分析工具');
|
||||||
expect(wrapper.text()).toContain('工程基线已就绪');
|
expect(wrapper.text()).toContain('市场综合概览');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ const appTitle = import.meta.env.VITE_APP_TITLE || 'A 股市场分析工具';
|
|||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="app-header" aria-label="应用导航">
|
<header class="app-header" aria-label="应用导航">
|
||||||
|
<div class="app-identity">
|
||||||
<span class="app-mark" aria-hidden="true">A</span>
|
<span class="app-mark" aria-hidden="true">A</span>
|
||||||
|
<div>
|
||||||
<strong>{{ appTitle }}</strong>
|
<strong>{{ appTitle }}</strong>
|
||||||
|
<small>市场复盘工作台</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="header-context">只读行情 · TDX</span>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" class="app-main">
|
<main id="main-content" class="app-main">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import {
|
|||||||
type RouterHistory,
|
type RouterHistory,
|
||||||
type RouteRecordRaw,
|
type RouteRecordRaw,
|
||||||
} from 'vue-router';
|
} from 'vue-router';
|
||||||
import { h } from 'vue';
|
|
||||||
|
import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: {
|
component: MarketOverviewPage,
|
||||||
// 工程基线不引入运行时模板编译器;业务首页落地前用渲染函数提供可观测占位内容。
|
|
||||||
render: () => h('section', { 'aria-label': '应用就绪' }, [h('p', '工程基线已就绪')]),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+532
-1
@@ -44,7 +44,7 @@ textarea {
|
|||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -52,6 +52,29 @@ textarea {
|
|||||||
border-bottom: 1px solid #343b44;
|
border-bottom: 1px solid #343b44;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-identity strong,
|
||||||
|
.app-identity small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-identity small {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #aeb7c2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-context {
|
||||||
|
color: #aeb7c2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-mark {
|
.app-mark {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -69,10 +92,518 @@ textarea {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.market-overview-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
background: #e8edf1;
|
||||||
|
border: 1px solid #d5dce3;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button,
|
||||||
|
.refresh-button,
|
||||||
|
.quiet-button,
|
||||||
|
.sector-list button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button {
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: #4c5966;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button.active {
|
||||||
|
color: #17212b;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgb(28 40 52 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: #65717d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions select {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 28px 0 9px;
|
||||||
|
color: #24303b;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccd4dc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: #167d82;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-conclusion,
|
||||||
|
.overview-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe1e6;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 3px 14px rgb(26 38 49 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-conclusion {
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
color: #167d82;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading,
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading h1,
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #17212b;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading h1 {
|
||||||
|
font-size: clamp(24px, 3vw, 38px);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-copy {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #475563;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px 7px;
|
||||||
|
min-width: 142px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: #167d82;
|
||||||
|
background: #eff8f7;
|
||||||
|
border: 1px solid #cbe6e2;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #6d7a85;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status[data-status='unavailable'] {
|
||||||
|
color: #a45e1b;
|
||||||
|
background: #fff7ec;
|
||||||
|
border-color: #f0d7b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: currentcolor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 21px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #e6eaee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts span,
|
||||||
|
.panel-meta,
|
||||||
|
.breadth-values span,
|
||||||
|
.column-label {
|
||||||
|
color: #7b8792;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts strong {
|
||||||
|
color: #2e3b47;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-warning {
|
||||||
|
margin: 13px 0 0;
|
||||||
|
color: #a45e1b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel .module-warning {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #b67a2d;
|
||||||
|
background: #fff8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel .module-warning strong {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid-top {
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid-bottom {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel {
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet-button {
|
||||||
|
padding: 6px 9px;
|
||||||
|
color: #167d82;
|
||||||
|
background: #eff8f7;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drivers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column + .driver-column {
|
||||||
|
padding-left: 17px;
|
||||||
|
border-left: 1px solid #e6eaee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label.positive,
|
||||||
|
.positive {
|
||||||
|
color: #c33f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label.negative,
|
||||||
|
.negative {
|
||||||
|
color: #19846e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #485664;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column li::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.62em;
|
||||||
|
left: 0;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: #aeb8c1;
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 13px;
|
||||||
|
color: #4c5966;
|
||||||
|
text-align: left;
|
||||||
|
background: #f8fafb;
|
||||||
|
border: 1px solid #e3e8ec;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item:hover {
|
||||||
|
border-color: #a5c9c7;
|
||||||
|
box-shadow: 0 2px 8px rgb(26 38 49 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item strong {
|
||||||
|
color: #1c2a35;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item small {
|
||||||
|
color: #7d8893;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item[data-direction='up'] small {
|
||||||
|
color: #c33f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item[data-direction='down'] small {
|
||||||
|
color: #19846e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadth-values {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadth-values div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadth-values strong {
|
||||||
|
color: #2e3b47;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-placeholder,
|
||||||
|
.module-unavailable {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
min-height: 104px;
|
||||||
|
gap: 6px;
|
||||||
|
color: #8a959f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-unavailable strong {
|
||||||
|
color: #a45e1b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-unavailable span {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 112px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: #4c5966;
|
||||||
|
background: #f3f7f7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button strong {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button[data-direction='up'] strong,
|
||||||
|
.turnover-trend [data-direction='up'] {
|
||||||
|
color: #c33f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button[data-direction='down'] strong,
|
||||||
|
.turnover-trend [data-direction='down'] {
|
||||||
|
color: #19846e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend [data-direction='flat'] {
|
||||||
|
color: #6f7a84;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend strong {
|
||||||
|
color: #1c2a35;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend small,
|
||||||
|
.turnover-trend p {
|
||||||
|
color: #7d8893;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.overview-grid-top,
|
||||||
|
.overview-grid-bottom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.app-header,
|
.app-header,
|
||||||
.app-main {
|
.app-main {
|
||||||
padding-right: 14px;
|
padding-right: 14px;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-context {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-toolbar,
|
||||||
|
.conclusion-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-conclusion {
|
||||||
|
padding: 18px 16px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading h1 {
|
||||||
|
font-size: 27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drivers-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column + .driver-column {
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid #e6eaee;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { components } from '@/api/generated/schema';
|
||||||
|
|
||||||
|
export type SummaryResponse = components['schemas']['SummaryResponseDto'];
|
||||||
|
export type IndicesResponse = components['schemas']['IndicesResponseDto'];
|
||||||
|
export type BreadthResponse = components['schemas']['BreadthResponseDto'];
|
||||||
|
export type SectorsResponse = components['schemas']['SectorsResponseDto'];
|
||||||
|
export type FundsResponse = components['schemas']['FundsResponseDto'];
|
||||||
|
export type SummaryData = NonNullable<SummaryResponse['data']>;
|
||||||
|
export type IndicesData = NonNullable<IndicesResponse['data']>;
|
||||||
|
export type BreadthData = NonNullable<BreadthResponse['data']>;
|
||||||
|
export type SectorsData = NonNullable<SectorsResponse['data']>;
|
||||||
|
export type FundsData = NonNullable<FundsResponse['data']>;
|
||||||
|
|
||||||
|
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import MarketBreadth from './components/MarketBreadth.vue';
|
||||||
|
import MarketConclusion from './components/MarketConclusion.vue';
|
||||||
|
import MarketDrivers from './components/MarketDrivers.vue';
|
||||||
|
import SectorOverview from './components/SectorOverview.vue';
|
||||||
|
import FundTrend from './components/FundTrend.vue';
|
||||||
|
import IndexOverview from './components/IndexOverview.vue';
|
||||||
|
import { useMarketOverview } from './useMarketOverview';
|
||||||
|
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
indices,
|
||||||
|
breadth,
|
||||||
|
sectors,
|
||||||
|
funds,
|
||||||
|
isLoading,
|
||||||
|
analysisHorizon,
|
||||||
|
analysisEngine,
|
||||||
|
refresh,
|
||||||
|
setAnalysisHorizon,
|
||||||
|
setAnalysisEngine,
|
||||||
|
} = useMarketOverview();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openDetail(label: string) {
|
||||||
|
// 详情页尚未立项,先保留明确入口并避免伪造导航地址。
|
||||||
|
console.info(`市场概览详情入口:${label}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="market-overview-page">
|
||||||
|
<div class="overview-toolbar">
|
||||||
|
<div class="view-switcher" aria-label="分析视角">
|
||||||
|
<button
|
||||||
|
v-for="option in ['综合', '短线', '中长线']"
|
||||||
|
:key="option"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: analysisHorizon === option }"
|
||||||
|
@click="setAnalysisHorizon(option)"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label>
|
||||||
|
<span>分析引擎</span>
|
||||||
|
<select
|
||||||
|
:value="analysisEngine"
|
||||||
|
@change="setAnalysisEngine(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option>规则</option>
|
||||||
|
<option>AI</option>
|
||||||
|
<option>混合</option>
|
||||||
|
<option>对照</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="refresh-button"
|
||||||
|
:disabled="isLoading"
|
||||||
|
title="重新读取 TDX 行情"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
{{ isLoading ? '读取中…' : '刷新行情' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MarketConclusion :response="summary.response" :loading="summary.status === 'loading'" />
|
||||||
|
|
||||||
|
<div class="overview-grid overview-grid-top">
|
||||||
|
<MarketDrivers :response="summary.response" />
|
||||||
|
<IndexOverview
|
||||||
|
:response="indices.response"
|
||||||
|
:loading="indices.status === 'loading'"
|
||||||
|
@select="openDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overview-grid overview-grid-bottom">
|
||||||
|
<MarketBreadth :response="breadth.response" :loading="breadth.status === 'loading'" />
|
||||||
|
<SectorOverview
|
||||||
|
:response="sectors.response"
|
||||||
|
:loading="sectors.status === 'loading'"
|
||||||
|
@select="openDetail"
|
||||||
|
/>
|
||||||
|
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FundsResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
||||||
|
|
||||||
|
function formatTradeDate(value: string | undefined): string {
|
||||||
|
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="funds-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">量能观察</span>
|
||||||
|
<h2 id="funds-title">成交额趋势</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">{{ formatTradeDate(response?.data?.tradeDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="response?.data">
|
||||||
|
<div v-if="response.status !== 'fresh'" class="module-warning">
|
||||||
|
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="turnover-trend">
|
||||||
|
<div>
|
||||||
|
<span>{{ response.data.marketCount }} 市成交额</span>
|
||||||
|
<strong>{{ response.data.currentAmount.toLocaleString('zh-CN') }}</strong>
|
||||||
|
<small>{{ response.data.unit }}</small>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
:data-direction="
|
||||||
|
response.data.changePercent === 0
|
||||||
|
? 'flat'
|
||||||
|
: response.data.changePercent > 0
|
||||||
|
? 'up'
|
||||||
|
: 'down'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
较前一交易日
|
||||||
|
{{ response.data.changePercent > 0 ? '+' : ''
|
||||||
|
}}{{ response.data.changePercent.toFixed(2) }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IndicesResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: IndicesResponse | null; loading: boolean }>();
|
||||||
|
const emit = defineEmits<{ select: [code: string] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="indices-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">核心指数</span>
|
||||||
|
<h2 id="indices-title">主要市场温度</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">{{ response?.asOf ? response.asOf.slice(11, 19) : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取指数…</div>
|
||||||
|
<div v-else-if="!response?.data?.items.length" class="module-placeholder">
|
||||||
|
TDX 指数数据不可用
|
||||||
|
</div>
|
||||||
|
<div v-else class="index-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in response.data.items"
|
||||||
|
:key="item.code"
|
||||||
|
type="button"
|
||||||
|
class="index-item"
|
||||||
|
:data-direction="item.direction"
|
||||||
|
:title="`进入${item.name}详细分析`"
|
||||||
|
@click="emit('select', item.code)"
|
||||||
|
>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<strong>{{ item.price.toFixed(2) }}</strong>
|
||||||
|
<small>{{
|
||||||
|
item.changePercent == null
|
||||||
|
? '—'
|
||||||
|
: `${item.changePercent > 0 ? '+' : ''}${item.changePercent.toFixed(2)}%`
|
||||||
|
}}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { BreadthResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: BreadthResponse | null; loading: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="breadth-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">市场宽度</span>
|
||||||
|
<h2 id="breadth-title">涨跌分布与量能</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">分钟级</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取市场宽度…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="response?.data">
|
||||||
|
<div v-if="response.status !== 'fresh'" class="module-warning">
|
||||||
|
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="breadth-values">
|
||||||
|
<div>
|
||||||
|
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="module-placeholder">暂无宽度数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SummaryResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
response: SummaryResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-conclusion" aria-labelledby="market-overview-title">
|
||||||
|
<div class="section-kicker">市场综合概览</div>
|
||||||
|
<div class="conclusion-heading">
|
||||||
|
<div>
|
||||||
|
<h1 id="market-overview-title">盘面先看结论,再看证据</h1>
|
||||||
|
<p class="conclusion-copy">
|
||||||
|
{{ loading ? '正在读取 TDX 行情…' : response?.data?.verdict || 'TDX 数据暂不可用' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="source-status" :data-status="response?.status || 'unavailable'">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span>{{ response?.source === 'tdx' ? 'TDX 行情' : '等待数据' }}</span>
|
||||||
|
<small>{{ response?.asOf ? `更新于 ${response.asOf}` : '尚未连接' }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conclusion-facts">
|
||||||
|
<div>
|
||||||
|
<span>市场状态</span>
|
||||||
|
<strong>{{ response?.data?.marketStatus || '待确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>量能状态</span>
|
||||||
|
<strong>{{ response?.data?.volumeStatus || '待确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>情绪状态</span>
|
||||||
|
<strong>{{ response?.data?.sentimentStatus || '待确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="response?.warnings.length" class="module-warning">{{ response.warnings[0] }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import FundTrend from './FundTrend.vue';
|
||||||
|
import MarketBreadth from './MarketBreadth.vue';
|
||||||
|
import SectorOverview from './SectorOverview.vue';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
snapshotId: '20260818-150000',
|
||||||
|
asOf: '2026-08-18T15:00:00.000Z',
|
||||||
|
source: 'tdx' as const,
|
||||||
|
status: 'fresh' as const,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('市场概览真实数据面板', () => {
|
||||||
|
it('市场宽度对未知平盘家数显示占位符而不是零', () => {
|
||||||
|
const wrapper = mount(MarketBreadth, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: { ...meta, data: { advancing: 2121, declining: 3292, unchanged: null } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('2121');
|
||||||
|
expect(wrapper.text()).toContain('3292');
|
||||||
|
expect(wrapper.text()).toContain('平盘—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块展示名称和涨跌幅并以代码触发详情入口', async () => {
|
||||||
|
const wrapper = mount(SectorOverview, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
code: '881319.SH',
|
||||||
|
name: '半导体',
|
||||||
|
changePercent: 2.56,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('半导体');
|
||||||
|
expect(wrapper.text()).toContain('+2.56%');
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
expect(wrapper.emitted('select')).toEqual([['881319.SH']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度 partial 状态显示警告且保留有效数据', () => {
|
||||||
|
const wrapper = mount(MarketBreadth, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
status: 'partial',
|
||||||
|
warnings: ['部分市场涨跌家数不可用:北证市场'],
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('部分数据');
|
||||||
|
expect(wrapper.text()).toContain('北证市场');
|
||||||
|
expect(wrapper.text()).toContain('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块 delayed 状态显示警告和真实交易日期', () => {
|
||||||
|
const wrapper = mount(SectorOverview, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
status: 'delayed',
|
||||||
|
warnings: ['板块数据已延迟'],
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
code: '881319.SH',
|
||||||
|
name: '半导体',
|
||||||
|
changePercent: 2.56,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('数据延迟');
|
||||||
|
expect(wrapper.text()).toContain('板块数据已延迟');
|
||||||
|
expect(wrapper.text()).toContain('2026-08-18');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('资金模块明确展示成交额和与上一交易日的变化', () => {
|
||||||
|
const wrapper = mount(FundTrend, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
data: {
|
||||||
|
currentAmount: 12345.67,
|
||||||
|
previousAmount: 11000,
|
||||||
|
changePercent: 12.23,
|
||||||
|
unit: '亿元' as const,
|
||||||
|
marketCount: 3,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('成交额趋势');
|
||||||
|
expect(wrapper.text()).toContain('12,345.67');
|
||||||
|
expect(wrapper.text()).toContain('较前一交易日 +12.23%');
|
||||||
|
expect(wrapper.text()).not.toContain('主力净流入');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('部分数据保留数值并显示警告和实际交易日期', () => {
|
||||||
|
const wrapper = mount(FundTrend, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
status: 'partial',
|
||||||
|
warnings: ['部分市场成交额不可用,当前汇总 2 个市场'],
|
||||||
|
data: {
|
||||||
|
currentAmount: 100,
|
||||||
|
previousAmount: 100,
|
||||||
|
changePercent: 0,
|
||||||
|
unit: '亿元' as const,
|
||||||
|
marketCount: 2,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('部分数据');
|
||||||
|
expect(wrapper.text()).toContain('部分市场成交额不可用');
|
||||||
|
expect(wrapper.text()).toContain('2 市成交额');
|
||||||
|
expect(wrapper.text()).toContain('2026-08-18');
|
||||||
|
expect(wrapper.get('[data-direction="flat"]').attributes('data-direction')).toBe('flat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SummaryResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: SummaryResponse | null }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel drivers-panel" aria-labelledby="drivers-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">判断依据</span>
|
||||||
|
<h2 id="drivers-title">驱动与风险</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="quiet-button" title="查看当前结论依据">查看依据</button>
|
||||||
|
</div>
|
||||||
|
<div class="drivers-grid">
|
||||||
|
<div class="driver-column">
|
||||||
|
<span class="column-label positive">驱动</span>
|
||||||
|
<ul>
|
||||||
|
<li v-for="driver in response?.data?.drivers || ['等待 TDX 数据']" :key="driver">
|
||||||
|
{{ driver }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="driver-column">
|
||||||
|
<span class="column-label negative">风险</span>
|
||||||
|
<ul>
|
||||||
|
<li v-for="risk in response?.data?.risks || ['等待 TDX 数据']" :key="risk">{{ risk }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SectorsResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: SectorsResponse | null; loading: boolean }>();
|
||||||
|
const emit = defineEmits<{ select: [sector: string] }>();
|
||||||
|
|
||||||
|
function formatTradeDate(value: string | undefined): string {
|
||||||
|
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="sectors-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">板块扫描</span>
|
||||||
|
<h2 id="sectors-title">强弱分布</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">{{ formatTradeDate(response?.data?.items[0]?.tradeDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取板块…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="response?.data?.items.length">
|
||||||
|
<div v-if="response.status !== 'fresh'" class="module-warning">
|
||||||
|
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sector-list">
|
||||||
|
<button
|
||||||
|
v-for="sector in response.data.items"
|
||||||
|
:key="sector.code"
|
||||||
|
type="button"
|
||||||
|
:data-direction="sector.direction"
|
||||||
|
:title="`进入${sector.name}详细分析`"
|
||||||
|
@click="emit('select', sector.code)"
|
||||||
|
>
|
||||||
|
<span>{{ sector.name }}</span>
|
||||||
|
<strong>{{
|
||||||
|
`${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
|
||||||
|
}}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="module-placeholder">暂无板块数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useMarketOverview } from './useMarketOverview';
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: null,
|
||||||
|
snapshotId: '20260818-101500',
|
||||||
|
asOf: '2026-08-18T10:15:00+08:00',
|
||||||
|
warnings: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe('市场概览状态', () => {
|
||||||
|
it('并行加载五个模块,一个模块失败不影响其他模块', async () => {
|
||||||
|
const api = {
|
||||||
|
getSummary: vi.fn().mockResolvedValue(response),
|
||||||
|
getIndices: vi.fn().mockRejectedValue(new Error('failed')),
|
||||||
|
getBreadth: vi.fn().mockResolvedValue(response),
|
||||||
|
getSectors: vi.fn().mockResolvedValue(response),
|
||||||
|
getFunds: vi.fn().mockResolvedValue(response),
|
||||||
|
};
|
||||||
|
const state = useMarketOverview(api);
|
||||||
|
|
||||||
|
await state.refresh();
|
||||||
|
|
||||||
|
expect(state.summary.value.status).toBe('ready');
|
||||||
|
expect(state.indices.value.status).toBe('error');
|
||||||
|
expect(state.breadth.value.status).toBe('ready');
|
||||||
|
expect(state.isLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分析偏好写入本地存储并可恢复', () => {
|
||||||
|
const first = useMarketOverview({} as never);
|
||||||
|
first.setAnalysisHorizon('短线');
|
||||||
|
first.setAnalysisEngine('混合');
|
||||||
|
|
||||||
|
const second = useMarketOverview({} as never);
|
||||||
|
|
||||||
|
expect(second.analysisHorizon.value).toBe('短线');
|
||||||
|
expect(second.analysisEngine.value).toBe('混合');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import marketOverviewApi, { type createMarketOverviewApi } from '@/api/market-overview';
|
||||||
|
import type {
|
||||||
|
BreadthResponse,
|
||||||
|
FundsResponse,
|
||||||
|
IndicesResponse,
|
||||||
|
OverviewModuleStatus,
|
||||||
|
SectorsResponse,
|
||||||
|
SummaryResponse,
|
||||||
|
} from '@/types/market-overview';
|
||||||
|
|
||||||
|
type MarketResponse =
|
||||||
|
SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
|
||||||
|
|
||||||
|
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
||||||
|
status: OverviewModuleStatus;
|
||||||
|
response: TResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState<TResponse extends MarketResponse>(): OverviewModuleState<TResponse> {
|
||||||
|
return { status: 'idle', response: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreference(key: string, fallback: string): string {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key) || fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePreference(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// 隐私模式或禁用存储时只保留本次页面状态,不让偏好存储阻断行情展示。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarketOverview(
|
||||||
|
api: ReturnType<typeof createMarketOverviewApi> = marketOverviewApi,
|
||||||
|
) {
|
||||||
|
const summary = ref<OverviewModuleState<SummaryResponse>>(createState());
|
||||||
|
const indices = ref<OverviewModuleState<IndicesResponse>>(createState());
|
||||||
|
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
|
||||||
|
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
|
||||||
|
const funds = ref<OverviewModuleState<FundsResponse>>(createState());
|
||||||
|
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
|
||||||
|
const analysisEngine = ref(readPreference('a-share-analysis:overview-engine', '规则'));
|
||||||
|
const isLoading = computed(() =>
|
||||||
|
[summary.value, indices.value, breadth.value, sectors.value, funds.value].some(
|
||||||
|
(module) => module.status === 'loading',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function setLoading() {
|
||||||
|
summary.value.status = 'loading';
|
||||||
|
indices.value.status = 'loading';
|
||||||
|
breadth.value.status = 'loading';
|
||||||
|
sectors.value.status = 'loading';
|
||||||
|
funds.value.status = 'loading';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModule<TResponse extends MarketResponse>(
|
||||||
|
state: { value: OverviewModuleState<TResponse> },
|
||||||
|
loader: () => Promise<TResponse>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
state.value.response = await loader();
|
||||||
|
state.value.status = 'ready';
|
||||||
|
} catch {
|
||||||
|
state.value.response = null;
|
||||||
|
state.value.status = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
setLoading();
|
||||||
|
await Promise.all([
|
||||||
|
loadModule(summary, api.getSummary),
|
||||||
|
loadModule(indices, api.getIndices),
|
||||||
|
loadModule(breadth, api.getBreadth),
|
||||||
|
loadModule(sectors, api.getSectors),
|
||||||
|
loadModule(funds, api.getFunds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAnalysisHorizon(value: string) {
|
||||||
|
analysisHorizon.value = value;
|
||||||
|
writePreference('a-share-analysis:overview-horizon', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAnalysisEngine(value: string) {
|
||||||
|
analysisEngine.value = value;
|
||||||
|
writePreference('a-share-analysis:overview-engine', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
indices,
|
||||||
|
breadth,
|
||||||
|
sectors,
|
||||||
|
funds,
|
||||||
|
isLoading,
|
||||||
|
analysisHorizon,
|
||||||
|
analysisEngine,
|
||||||
|
refresh,
|
||||||
|
setAnalysisHorizon,
|
||||||
|
setAnalysisEngine,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ test('应用外壳在桌面和移动视口均可正常呈现', async ({ page })
|
|||||||
|
|
||||||
await expect(page.locator('main')).toBeVisible();
|
await expect(page.locator('main')).toBeVisible();
|
||||||
await expect(page.getByText('A 股市场分析工具')).toBeVisible();
|
await expect(page.getByText('A 股市场分析工具')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: '盘面先看结论,再看证据' })).toBeVisible();
|
||||||
|
|
||||||
const hasHorizontalOverflow = await page.evaluate(
|
const hasHorizontalOverflow = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
|
|||||||
Reference in New Issue
Block a user