From 0131f863b62daea1b3a61005aac205298e9cfa06 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 25 Aug 2026 00:16:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-004):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8C=87=E6=95=B0=E6=9D=BF=E5=9D=97=E8=AF=A6=E6=83=85=E5=88=86?= =?UTF-8?q?=E6=9E=90=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../FEAT-MO-004-index-sector-detail/design.md | 10 + .../gitnexus/before.md | 28 ++ .../gitnexus/impact-plan.md | 37 +++ .../implementation-plan.md | 6 + .../manifest.yaml | 9 + .../FEAT-MO-004-index-sector-detail/prd.md | 29 +++ .../requirements.md | 23 ++ .../rollback.md | 6 + .../tdx-capability-probe.md | 29 +++ .../plans/2026-08-24-index-sector-detail.md | 163 ++++++++++++ .../detail/market-detail.controller.ts | 80 ++++++ .../detail/market-detail.dto.ts | 100 +++++++ .../detail/market-detail.mapper.test.ts | 129 +++++++++ .../detail/market-detail.mapper.ts | 111 ++++++++ .../detail/market-detail.service.test.ts | 134 ++++++++++ .../detail/market-detail.service.ts | 169 ++++++++++++ .../market-overview/market-overview.module.ts | 5 +- .../apps/api/test/market-detail.e2e-spec.ts | 120 +++++++++ server/openapi/openapi.json | 196 ++++++++++++++ server/tools/export-openapi.ts | 10 +- web/src/api/generated/schema.ts | 144 +++++++++++ web/src/api/market-overview/detail.test.ts | 30 +++ web/src/api/market-overview/detail.ts | 28 ++ web/src/app/router.ts | 13 + web/src/styles/index.scss | 244 ++++++++++++++++++ .../market-overview/MarketOverviewPage.vue | 15 +- .../detail/MarketDetailChart.test.ts | 47 ++++ .../detail/MarketDetailChart.vue | 54 ++++ .../detail/MarketDetailPage.test.ts | 115 +++++++++ .../detail/MarketDetailPage.vue | 149 +++++++++++ .../market-overview/detail/market-detail.ts | 35 +++ 32 files changed, 2262 insertions(+), 8 deletions(-) create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/design.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/gitnexus/before.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/prd.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/requirements.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/rollback.md create mode 100644 docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md create mode 100644 docs/superpowers/plans/2026-08-24-index-sector-detail.md create mode 100644 server/apps/api/src/market-overview/detail/market-detail.controller.ts create mode 100644 server/apps/api/src/market-overview/detail/market-detail.dto.ts create mode 100644 server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts create mode 100644 server/apps/api/src/market-overview/detail/market-detail.mapper.ts create mode 100644 server/apps/api/src/market-overview/detail/market-detail.service.test.ts create mode 100644 server/apps/api/src/market-overview/detail/market-detail.service.ts create mode 100644 server/apps/api/test/market-detail.e2e-spec.ts create mode 100644 web/src/api/market-overview/detail.test.ts create mode 100644 web/src/api/market-overview/detail.ts create mode 100644 web/src/views/market-overview/detail/MarketDetailChart.test.ts create mode 100644 web/src/views/market-overview/detail/MarketDetailChart.vue create mode 100644 web/src/views/market-overview/detail/MarketDetailPage.test.ts create mode 100644 web/src/views/market-overview/detail/MarketDetailPage.vue create mode 100644 web/src/views/market-overview/detail/market-detail.ts diff --git a/AGENTS.md b/AGENTS.md index b409a44..3b7db3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **a-share-analysis** (3472 symbols, 4950 relationships, 104 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** (3730 symbols, 5410 relationships, 115 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. diff --git a/docs/features/FEAT-MO-004-index-sector-detail/design.md b/docs/features/FEAT-MO-004-index-sector-detail/design.md new file mode 100644 index 0000000..87ce93c --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/design.md @@ -0,0 +1,10 @@ +# FEAT-MO-004 设计说明 + +设计基线为 `docs/superpowers/specs/2026-08-24-index-sector-detail-design.md`。 + +后端新增详情 Controller/Service/Mapper,复用现有 TDX Client 的超时、响应体上限和错误脱敏边界;指数和板块使用独立代码白名单和数据口径。前端新增详情路由、独立 Axios API 文件、共享详情页和轻量 SVG K 线图,概览按钮仅负责导航。 + +详情数据失败采用模块级降级,页面仍保留标题、来源、更新时间和 warning。分时能力不通过探测时不返回假数据,UI 只展示明确的不可用边界。 + +安全策略:Controller/Service 双层参数校验、代码白名单、固定周期、数量上限、响应字段白名单和错误脱敏。性能策略:单次最多 120 点、固定图表尺寸、页面卸载释放图表状态,不新增数据库和缓存热点。 + diff --git a/docs/features/FEAT-MO-004-index-sector-detail/gitnexus/before.md b/docs/features/FEAT-MO-004-index-sector-detail/gitnexus/before.md new file mode 100644 index 0000000..f8d151b --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/gitnexus/before.md @@ -0,0 +1,28 @@ +# GitNexus 开发前基线 + +- 功能编号:FEAT-MO-004 +- 记录时间:2026年8月24日 GMT+8 23:37:42 +- 分支:main +- 基线提交:f8f4da93dc8f + +## 工作区 + +```text +?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv" +?? docs/features/FEAT-MO-004-index-sector-detail/ +?? docs/superpowers/plans/2026-08-24-index-sector-detail.md +``` + +## GitNexus 状态 + +```text +Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis +Indexed: 8/24/2026, 11:37:41 PM +Indexed commit: f8f4da9 +Current commit: f8f4da9 +Status: ✅ up-to-date +``` + +## 后续人工分析 + +在修改代码前,将 GitNexus query、context、impact 的结论写入 `gitnexus/impact-plan.md`。高风险结果必须先获得用户确认。 diff --git a/docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md b/docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md new file mode 100644 index 0000000..6be04c1 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md @@ -0,0 +1,37 @@ +# GitNexus 开发前影响计划 + +- 功能编号:FEAT-MO-004 +- 总体风险:MEDIUM(既有概览服务扩展会触及 Controller、模块、服务测试和 OpenAPI 导出;无 HIGH/CRITICAL) + +## 直接与间接影响 + +### 查询与上下文 + +- GitNexus 仓库:`a-share-analysis`,开发前索引与 `f8f4da9` 一致且 up-to-date。 +- 影响目标:`MarketOverviewController`、`MarketOverviewService`、`createTdxClient`、`createAppRouter`。 +- `MarketOverviewController` upstream:3 个直接文件消费者(概览 E2E、OpenAPI 导出、模块注册),总影响 7 个文件,风险 LOW。 +- `MarketOverviewService` upstream:5 个直接文件消费者(概览 E2E、服务测试、OpenAPI 导出、Controller、模块),总影响 9 个文件,风险 MEDIUM。 +- `createTdxClient` upstream:TDX Client 单元测试 1 个直接消费者,风险 LOW;详情复用其稳定 `request()` 边界,不修改协议实现。 +- `createAppRouter` upstream:`App.test.ts` 1 个直接消费者,风险 LOW;新增路由需要补充路由测试和根路径回归。 +- `MarketOverviewPage` 未被索引为符号,按文件级人工检查确认只消费概览 composable 和两个选择事件;新增导航不改变概览请求契约。 +- 执行流:详情是新增只读 HTTP 流程;既有首页五个概览流程仅通过模块注册和共享 TDX Client 间接关联,需回归验证。 + +## 跨模块同步修改 + +| 范围 | 判断 | 同步动作 | +| --- | --- | --- | +| HTTP/OpenAPI | 需要 | 新增两个详情路由、DTO、生成 OpenAPI 并校验 | +| Web API | 需要 | 新增详情 Axios 文件与类型适配 | +| 消息契约 | 不需要 | 只读同步请求,不产生消息 | +| 数据库/Redis | 不需要 | 不持久化、不缓存,且不触碰生产库 | +| API 进程 | 需要 | 注册详情 Controller/Service,复用已校验 TDX Client | +| Scheduler/Worker | 不需要 | 无异步任务或定时采集 | +| 配置/部署 | 不需要 | 继续使用现有 TDX 配置和内网隧道,不新增环境变量 | +| 测试 | 需要 | Mapper、Service、HTTP、Web 路由/状态、视觉和全量门禁 | +| 文档 | 需要 | 功能档案、TDX 探测、开发总结、Review、QA、GitNexus 前后对比 | + +### 设计决策 + +1. 不扩展首页接口大对象,详情接口独立,降低既有消费者的契约风险。 +2. 不修改 `createTdxClient`,只复用其超时、响应体上限和错误脱敏;分时能力未验证时明确返回不可用。 +3. 指数和板块共享页面外壳与 K 线点类型,但保留各自白名单和业务摘要映射。 diff --git a/docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md b/docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md new file mode 100644 index 0000000..a698768 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md @@ -0,0 +1,6 @@ +# FEAT-MO-004 实施记录 + +详细实施计划见 [`docs/superpowers/plans/2026-08-24-index-sector-detail.md`](../../../superpowers/plans/2026-08-24-index-sector-detail.md)。 + +执行顺序:开发前 Schema/GitNexus 门禁 → TDX 能力探测 → Mapper → Service/Controller → Web 路由和详情页 → 契约、测试、视觉验证、Review 和治理收尾。 + diff --git a/docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml b/docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml new file mode 100644 index 0000000..a114454 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml @@ -0,0 +1,9 @@ +featureId: FEAT-MO-004 +slug: index-sector-detail +name: 指数与板块详情分析页 +status: in_progress +release: unreleased +source: tdx +scope: internal_lan_only +createdAt: 2026-08-24 + diff --git a/docs/features/FEAT-MO-004-index-sector-detail/prd.md b/docs/features/FEAT-MO-004-index-sector-detail/prd.md new file mode 100644 index 0000000..3fc0ea0 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/prd.md @@ -0,0 +1,29 @@ +# FEAT-MO-004 指数与板块详情分析页 PRD + +## 原始需求 + +市场综合概览中的指数、板块可点击进入详细分析页,支持使用 TDX 真实数据进行复盘查看。 + +## 用户与场景 + +- 个人用户在概览页发现指数或强势板块后,进入详情确认交易日期、价格变化和 K 线走势。 +- 内网开发环境优先,TDX 通过当前 Mac 本机隧道访问;不把 TDX 暴露到公网。 + +## V1 范围 + +- 指数详情和一级行业板块详情。 +- 共享详情页外壳、数据状态、更新时间、来源、警告和 K 线图。 +- 分时视图由 TDX 能力探测结果决定,未通过时明确不可用。 +- 事实型数据和规则计算结果,不提供买卖建议、目标价、仓位建议或交易操作。 + +## 非目标 + +成分股排名、主力净流入、盘中新闻、AI 结论、用户体系、订阅付费和数据库/Redis/RocketMQ 持久化均不在本版本。 + +## 验收标准 + +1. 点击概览中的指数或板块可进入对应详情 URL,非法代码不触发 TDX 请求。 +2. API 返回 `instrument`、`quote`、`series`、`availableViews` 和统一元数据,K 线不超过 120 点。 +3. TDX 不可达、交易日延迟、字段缺失和空序列分别显示明确状态,不伪造图表。 +4. Web 显示上海时间,桌面和 390px 视口无横向溢出,图表容器尺寸稳定。 + diff --git a/docs/features/FEAT-MO-004-index-sector-detail/requirements.md b/docs/features/FEAT-MO-004-index-sector-detail/requirements.md new file mode 100644 index 0000000..8338060 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/requirements.md @@ -0,0 +1,23 @@ +# FEAT-MO-004 需求与约束 + +## 功能需求 + +- FR-001:提供指数详情 HTTP API。 +- FR-002:提供一级行业板块详情 HTTP API。 +- FR-003:详情 API 只接受登记代码、`period=1d` 和 `1..120` 的 `count`。 +- FR-004:详情响应包含 K 线序列、报价、交易日期、状态、来源和警告。 +- FR-005:概览指数和板块按钮导航到详情页,详情页可返回概览。 +- FR-006:分时仅在 TDX 探测通过后启用,未启用时返回 `intraday=false`。 + +## 非功能需求 + +- NFR-001:TDX 原始异常和地址不得暴露给 Web。 +- NFR-002:本机 TDX 可达时详情首屏 P95 目标小于 2 秒;不可达时 10 秒内返回不可用状态。 +- NFR-003:详情请求失败不影响首页五个概览模块。 +- NFR-004:API `asOf` 使用 UTC ISO,Web 使用 `Asia/Shanghai` 展示。 +- NFR-005:前端请求只能经过 Axios 封装,后端 DTO 作为 OpenAPI 契约来源。 + +## 约束判断 + +消息、数据库、Redis、Scheduler、Worker 和部署配置无需同步修改;详情为只读实时查询,不产生异步任务或持久化数据。OpenAPI、Web API、前端路由、测试和文档需要同步更新。 + diff --git a/docs/features/FEAT-MO-004-index-sector-detail/rollback.md b/docs/features/FEAT-MO-004-index-sector-detail/rollback.md new file mode 100644 index 0000000..ce42943 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/rollback.md @@ -0,0 +1,6 @@ +# FEAT-MO-004 回退说明 + +详情功能为无数据库结构变更的只读模块。若发布后出现问题,先回退前端详情路由和概览导航,再回退后端详情 Controller/Service 注册;首页已有五个概览接口不依赖详情模块。回退不执行生产数据库操作、不删除 TDX 配置、不修改用户数据。 + +回退验证包括:`GET /api/v1/market-overview/indices`、`sectors` 仍返回 200;Web 根路径可加载;OpenAPI 与生成契约重新生成并通过边界测试。 + diff --git a/docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md b/docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md new file mode 100644 index 0000000..b5bff38 --- /dev/null +++ b/docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md @@ -0,0 +1,29 @@ +# FEAT-MO-004 TDX 能力探测报告 + +- 探测日期:2026-08-24(开发环境) +- 数据源:TDX 开发隧道(地址和请求体按安全规则不记录) +- 探测方式:只读 JSON-RPC 请求,输出仅保留状态码、耗时、字段名和脱敏代码。 + +## 已通过能力 + +| 能力 | 方法 | 结果 | 观测 | +| --- | --- | --- | --- | +| 指数日 K 线 | `get_market_data` | 通过 | HTTP 200,业务 `ErrorId=0`,返回 `Value` 及分页元数据;两条核心指数请求耗时约 56ms | +| 一级行业目录 | `get_stock_list` | 通过 | HTTP 200,业务 `ErrorId=0`,返回数组,首个脱敏记录代码为 `881001.SH` | +| 一级行业日 K 线 | `get_market_data` | 通过 | 对目录首个代码请求 5 条日线,HTTP 200,业务 `ErrorId=0`,返回对应 `Value`;耗时约 47ms | + +## 未通过能力 + +| 能力 | 候选方法 | 结果 | 处理 | +| --- | --- | --- | --- | +| 分时 | `get_minute_data` | 未通过 | 服务返回协议错误,未暴露原始错误内容;V1 返回 `availableViews.intraday=false` | +| 分时 | `get_minute_data_ex` | 未通过 | 服务返回协议错误;不继续猜测参数或伪造分时序列 | +| 历史分时 | `get_history_minute_data` | 未通过 | 服务返回协议错误;延期到独立能力验证 | + +## 安全和稳定性结论 + +- 所有探测均为固定只读方法,未调用交易、账户、自选或写入接口。 +- 日 K 线字段沿用已验证的 `Date/Open/High/Low/Close/Amount/Volume`;详情服务仍会二次校验字段长度、日期和数量上限。 +- 真实响应只用于能力判断,不作为提交到仓库的原始样本;报告不包含地址、请求体、凭据或原始异常。 +- 本次观测不代表生产容量证明;详情页仍按单请求最多 120 点和现有 TDX 超时配置执行。 + diff --git a/docs/superpowers/plans/2026-08-24-index-sector-detail.md b/docs/superpowers/plans/2026-08-24-index-sector-detail.md new file mode 100644 index 0000000..bfd1d30 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-index-sector-detail.md @@ -0,0 +1,163 @@ +# 指数与板块详情分析页实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在市场综合概览中实现可点击的指数详情页,并在同一外壳上接入一级行业板块详情;只展示经过 TDX 校验的事实数据和规则计算结果。 + +**Architecture:** 后端新增详情业务模块,复用现有 TDX Client 和概览元数据,但不把详情数据拼入首页接口。详情 Controller 负责参数白名单与查询上限,Service 按指数/板块分别读取和映射日 K 线,Web 通过独立 Axios API 和共享详情页面外壳展示状态、K 线与可用视图;分时能力只有在探测报告通过后才启用。 + +**Tech Stack:** NestJS 11、TypeScript 5.9、Vue 3、Vue Router 4、Axios、Naive UI、原生 SVG/Canvas(除非依赖评估证明需要新增图表库)、TDX JSON-RPC。 + +**Spec:** `docs/superpowers/specs/2026-08-24-index-sector-detail-design.md` + +## Global Constraints + +- Node 使用 `22.x`,MySQL 仅保留 `5.7.x` 兼容约束,排序规则为 `utf8mb4_general_ci`。 +- 详情接口只读 TDX,不启用 MySQL、Redis、RocketMQ、AI 或用户体系。 +- TDX 原始地址、请求体、凭据和原始异常不得进入 Web 响应;错误必须分类并脱敏。 +- API 响应 `asOf` 保持 UTC ISO,Web 统一按 `Asia/Shanghai` 展示。 +- 每个详情请求最多返回 120 个 K 线点;`period` 仅接受已验证的 `1d`。 +- 不提交密钥、密码、`.env`、生产数据或 `.gitnexus/`;继续使用项目级 Gitea。 +- 修改代码前完成 GitNexus upstream impact;提交前运行 `gitnexus_detect_changes`。 +- 中文 Conventional Commit,提交验证通过后立即推送远程。 + +## 文件边界 + +### 新增 + +- `server/apps/api/src/market-overview/detail/market-detail.dto.ts`:详情响应、报价、K 线点和可用视图 OpenAPI DTO。 +- `server/apps/api/src/market-overview/detail/market-detail.mapper.ts`:指数/板块 K 线的安全字段映射、日期排序、数量裁剪和状态辅助。 +- `server/apps/api/src/market-overview/detail/market-detail.service.ts`:详情查询、业务代码白名单、TDX 错误降级和指数/板块口径分流。 +- `server/apps/api/src/market-overview/detail/market-detail.controller.ts`:两个详情路由、查询参数校验和 Swagger 声明。 +- `server/apps/api/src/market-overview/detail/market-detail.service.test.ts`、`market-detail.mapper.test.ts`:服务与映射单元测试。 +- `server/apps/api/test/market-detail.e2e-spec.ts`:HTTP 参数、响应状态和脱敏测试。 +- `web/src/api/market-overview/detail.ts`:详情 API Axios 封装。 +- `web/src/views/market-overview/detail/MarketDetailPage.vue`:共享详情页外壳和加载/错误/延迟状态。 +- `web/src/views/market-overview/detail/MarketDetailChart.vue`:固定尺寸的轻量 SVG K 线图和不可用边界。 +- `web/src/views/market-overview/detail/market-detail.ts`:前端详情响应类型适配与展示辅助。 +- `web/src/views/market-overview/detail/MarketDetailPage.test.ts`、`MarketDetailChart.test.ts`:路由状态、时区和图表输入测试。 +- `docs/features/FEAT-MO-004-index-sector-detail/`:PRD、需求、设计、计划、测试、回退、开发总结、GitNexus、Review 和 QA 证据。 + +### 修改 + +- `server/apps/api/src/market-overview/market-overview.module.ts`:注册详情 Controller/Service。 +- `server/apps/api/src/market-overview/tdx/tdx.types.ts`:仅在探测确认后补充分时响应类型;日 K 线已有类型保持兼容。 +- `web/src/app/router.ts`:新增 `/market-overview/indices/:code` 与 `/market-overview/sectors/:code` 路由。 +- `web/src/views/market-overview/MarketOverviewPage.vue`:把指数/板块选择事件改为真实路由导航。 +- `server/openapi/openapi.json`、`web/src/api/generated/schema.ts`:通过既有生成脚本更新契约,不手写漂移。 + +## 实施任务 + +### Task 1:建立功能档案、基线与影响分析 + +**Files:** +- Create: `docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/prd.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/requirements.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/design.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/rollback.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md` + +- [ ] 运行 `npm run schema:baseline`、`npm run schema:verify`;如果开发库为空,记录 `captured_empty`,不得伪造 SQL。 +- [ ] 运行 `npm run governance:start -- FEAT-MO-004 index-sector-detail`,确认基线已提交、已推送且 GitNexus up-to-date。 +- [ ] 对 `MarketOverviewController`、`MarketOverviewService`、`createTdxClient`、`createAppRouter` 和 `MarketOverviewPage` 执行 upstream impact,记录直接调用方、执行流、模块影响和风险。 +- [ ] 将 HTTP/OpenAPI、Web API、消息、数据库、Redis、Scheduler、Worker、部署、测试和文档的同步判断写入 `gitnexus/impact-plan.md`。 + +### Task 2:TDX 能力探测和脱敏证据 + +**Files:** +- Create: `docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md` +- Test: `server/apps/api/src/market-overview/tdx/tdx.client.test.ts`(只补充固定方法/参数边界测试,如确有必要) + +- [ ] 使用当前开发环境 TDX 隧道探测 `get_market_data` 的日 K 线、候选分时方法、时间字段、响应上限和一级行业代码历史 K 线。 +- [ ] 记录方法名、参数形状、返回字段、耗时、是否可重复和脱敏失败原因;禁止把地址、请求体凭据或原始异常写入文档。 +- [ ] 若分时协议或字段完整性未通过,只登记 `intraday=false`,不改造协议、不生成假数据。 + +### Task 3:先写详情 Mapper 失败测试并实现安全 K 线映射 + +**Files:** +- Create: `server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts` +- Create: `server/apps/api/src/market-overview/detail/market-detail.mapper.ts` +- Create: `server/apps/api/src/market-overview/detail/market-detail.dto.ts` + +**Interfaces:** +- `mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPoint[]` +- `mapDetailQuote(series: TdxKlineSeries, definition: DetailInstrumentDefinition): DetailQuoteDto | null` +- `trimAndSortSeries(points: DetailSeriesPoint[], count: number): DetailSeriesPoint[]` + +- [ ] 为空序列、日期非法、字段长度不一致、倒序日期、缺少金额/成交量、超过 120 条和相邻收盘价为零编写失败测试。 +- [ ] 实现只读取 `Date/Open/High/Low/Close/Amount/Volume`,按合法交易日期升序排序,单点字段缺失时返回 `null` 或降级状态,不抛出原始数据。 +- [ ] 实现价格、涨跌幅和方向计算;板块报价只暴露设计中确认的强弱字段。 +- [ ] 运行 mapper 定向测试并确认通过。 + +### Task 4:实现详情 Service、Controller 和 OpenAPI DTO + +**Files:** +- Create: `server/apps/api/src/market-overview/detail/market-detail.service.ts` +- Create: `server/apps/api/src/market-overview/detail/market-detail.controller.ts` +- Create: `server/apps/api/src/market-overview/detail/market-detail.service.test.ts` +- Modify: `server/apps/api/src/market-overview/market-overview.module.ts` + +**Interfaces:** +- `MarketDetailService.getIndexDetail(code: string, period: DetailPeriod, count: number): Promise` +- `MarketDetailService.getSectorDetail(code: string, period: DetailPeriod, count: number): Promise` +- `GET /api/v1/market-overview/indices/:code/detail?period=1d&count=120` +- `GET /api/v1/market-overview/sectors/:code/detail?period=1d&count=120` + +- [ ] 先写成功、非法代码、非法 period/count、TDX 超时、协议错误、空序列、部分字段缺失和延迟交易日测试。 +- [ ] Controller 只接受白名单指数/一级行业代码、`period=1d` 和 `1..120` 的整数 count;非法参数由 Nest 标准 400 返回。 +- [ ] Service 调用固定 `get_market_data`,不把方法名或原始 params 从 HTTP 传入;指数和板块使用独立定义表和 warning 文案。 +- [ ] 以 `fresh/delayed/partial/unavailable` 生成元数据;详情失败只影响本次请求,不影响首页接口。 +- [ ] 注册模块并生成 OpenAPI;运行 API 单元测试和 E2E 定向测试。 + +### Task 5:接入 Web 路由、Axios API 和共享详情页面 + +**Files:** +- Create: `web/src/api/market-overview/detail.ts` +- Create: `web/src/views/market-overview/detail/market-detail.ts` +- Create: `web/src/views/market-overview/detail/MarketDetailPage.vue` +- Create: `web/src/views/market-overview/detail/MarketDetailChart.vue` +- Create: `web/src/views/market-overview/detail/MarketDetailPage.test.ts` +- Create: `web/src/views/market-overview/detail/MarketDetailChart.test.ts` +- Modify: `web/src/app/router.ts` +- Modify: `web/src/views/market-overview/MarketOverviewPage.vue` +- Modify: `web/src/styles/index.scss` + +**Interfaces:** +- `createMarketDetailApi(requestClient): { getIndexDetail(code, params), getSectorDetail(code, params) }` +- `MarketDetailPage` consumes route `type/code`, calls only the Axios API wrapper, and renders `instrument/quote/series/availableViews`. + +- [ ] 先写路由参数、加载、空、错误、延迟、上海时区和 390px 视口测试。 +- [ ] 实现页面固定结构:返回概览、名称/代码/来源/更新时间、核心行情、K 线、数据质量和事实型复盘摘要。 +- [ ] 实现轻量 SVG 图表,固定容器尺寸,点数多于 120 时不额外扩张;`intraday=false` 只显示边界说明。 +- [ ] 修改概览点击事件使用 `router.push`,不在页面直接调用 Axios 或 TDX。 +- [ ] 运行 Web 定向测试、typecheck 和生产构建;确认移动端无横向溢出。 + +### Task 6:契约、全量验证与 Review + +**Files:** +- Modify: `server/openapi/openapi.json` +- Modify: `web/src/api/generated/schema.ts` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/test-plan.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/development-summary.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/review/code-review.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/review/standards-review.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/qa/test-results.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/qa/visual-qa.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/after.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/detected-changes.md` +- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/comparison.md` + +- [ ] 运行 `npm run check:contracts`,确认后端 DTO、OpenAPI 和 Web 类型一致。 +- [ ] 运行定向测试、`npm run verify`、开发服务器 Playwright 桌面/390px 截图和非空图表检查。 +- [ ] 执行代码 Review、开发规范符合性 Review,记录发现、处置和残余风险。 +- [ ] 运行 `npm run governance:finish -- FEAT-MO-004 index-sector-detail`,刷新 GitNexus 并保存前后对比。 +- [ ] 运行 `gitnexus_detect_changes`,确认只影响详情模块、路由、契约和预期文档。 +- [ ] 使用中文 Conventional Commit 提交并立即推送项目 Gitea;推送失败不得标记完成。 + +## 计划自检 + +- 设计覆盖:指数/板块双切片、TDX 能力门、状态降级、路由、契约、时区、安全、性能、测试、回退均有对应任务。 +- 占位检查:没有以 TBD/TODO 或“适当处理”代替具体行为;分时未验证时明确返回 `intraday=false`。 +- 类型一致性:Service 参数、DTO 字段、Web API 方法和页面消费字段与设计文档一致。 diff --git a/server/apps/api/src/market-overview/detail/market-detail.controller.ts b/server/apps/api/src/market-overview/detail/market-detail.controller.ts new file mode 100644 index 0000000..0deac11 --- /dev/null +++ b/server/apps/api/src/market-overview/detail/market-detail.controller.ts @@ -0,0 +1,80 @@ +import { BadRequestException, Controller, Get, Inject, Param, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; + +import { + IndexDetailResponseDto, + SectorDetailResponseDto, + type DetailPeriod, +} from './market-detail.dto'; +import { MarketDetailService } from './market-detail.service'; + +type DetailQuery = { period?: string; count?: string }; + +function parseDetailQuery(query: DetailQuery): { period: DetailPeriod; count: number } { + const period = query.period ?? '1d'; + if (period !== '1d') throw new BadRequestException('只支持 1d 周期'); + const count = query.count === undefined ? 120 : Number(query.count); + if ( + !/^\d+$/.test(query.count ?? '120') || + !Number.isSafeInteger(count) || + count < 1 || + count > 120 + ) { + throw new BadRequestException('count 必须是 1 到 120 的整数'); + } + return { period, count }; +} + +function assertCodeShape(code: string): void { + if (!/^\d{6}\.(SH|SZ|BJ)$/.test(code)) { + throw new BadRequestException('代码格式不可用'); + } +} + +@ApiTags('market-overview-detail') +@Controller('market-overview') +export class MarketDetailController { + constructor(@Inject(MarketDetailService) private readonly service: MarketDetailService) {} + + @Get('indices/:code/detail') + @ApiParam({ name: 'code', description: '带市场后缀的指数代码', example: '000001.SH' }) + @ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' }) + @ApiQuery({ + name: 'count', + required: false, + type: Number, + minimum: 1, + maximum: 120, + example: 120, + }) + @ApiOkResponse({ type: IndexDetailResponseDto }) + getIndexDetail( + @Param('code') code: string, + @Query() query: DetailQuery, + ): Promise { + assertCodeShape(code); + const parsed = parseDetailQuery(query); + return this.service.getIndexDetail(code, parsed.period, parsed.count); + } + + @Get('sectors/:code/detail') + @ApiParam({ name: 'code', description: '带市场后缀的一级行业代码', example: '881001.SH' }) + @ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' }) + @ApiQuery({ + name: 'count', + required: false, + type: Number, + minimum: 1, + maximum: 120, + example: 120, + }) + @ApiOkResponse({ type: SectorDetailResponseDto }) + getSectorDetail( + @Param('code') code: string, + @Query() query: DetailQuery, + ): Promise { + assertCodeShape(code); + const parsed = parseDetailQuery(query); + return this.service.getSectorDetail(code, parsed.period, parsed.count); + } +} diff --git a/server/apps/api/src/market-overview/detail/market-detail.dto.ts b/server/apps/api/src/market-overview/detail/market-detail.dto.ts new file mode 100644 index 0000000..eb58aac --- /dev/null +++ b/server/apps/api/src/market-overview/detail/market-detail.dto.ts @@ -0,0 +1,100 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +import { MarketOverviewMetaDto, type QuoteDirection } from '../market-overview.dto'; + +export type DetailInstrumentType = 'index' | 'sector'; +export type DetailPeriod = '1d'; + +export class DetailInstrumentDto { + @ApiProperty({ type: String }) + code!: string; + + @ApiProperty({ type: String }) + name!: string; + + @ApiProperty({ enum: ['index', 'sector'] }) + type!: DetailInstrumentType; +} + +export class DetailQuoteDto { + @ApiProperty({ type: Number }) + price!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + changePercent!: number | null; + + @ApiProperty({ enum: ['up', 'down', 'flat'] }) + direction!: QuoteDirection; + + @ApiProperty({ type: String, example: '20260818' }) + tradeDate!: string; +} + +export class DetailSeriesPointDto { + @ApiProperty({ type: String, example: '20260818' }) + time!: string; + + @ApiProperty({ type: Number }) + open!: number; + + @ApiProperty({ type: Number }) + high!: number; + + @ApiProperty({ type: Number }) + low!: number; + + @ApiProperty({ type: Number }) + close!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + amount!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + volume!: number | null; +} + +export class DetailAvailableViewsDto { + @ApiProperty({ type: Boolean }) + kline!: boolean; + + @ApiProperty({ type: Boolean }) + intraday!: boolean; +} + +export class IndexDetailDataDto { + @ApiProperty({ type: () => DetailInstrumentDto }) + instrument!: DetailInstrumentDto; + + @ApiProperty({ type: () => DetailQuoteDto }) + quote!: DetailQuoteDto; + + @ApiProperty({ type: [DetailSeriesPointDto] }) + series!: DetailSeriesPointDto[]; + + @ApiProperty({ type: () => DetailAvailableViewsDto }) + availableViews!: DetailAvailableViewsDto; +} + +export class SectorDetailDataDto { + @ApiProperty({ type: () => DetailInstrumentDto }) + instrument!: DetailInstrumentDto; + + @ApiProperty({ type: () => DetailQuoteDto }) + quote!: DetailQuoteDto; + + @ApiProperty({ type: [DetailSeriesPointDto] }) + series!: DetailSeriesPointDto[]; + + @ApiProperty({ type: () => DetailAvailableViewsDto }) + availableViews!: DetailAvailableViewsDto; +} + +export class IndexDetailResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => IndexDetailDataDto, nullable: true }) + data!: IndexDetailDataDto | null; +} + +export class SectorDetailResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => SectorDetailDataDto, nullable: true }) + data!: SectorDetailDataDto | null; +} diff --git a/server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts b/server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts new file mode 100644 index 0000000..eae52dc --- /dev/null +++ b/server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import type { TdxKlineSeries } from '../tdx/tdx.types'; +import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper'; + +function createSeries(overrides: Partial = {}): TdxKlineSeries { + return { + Date: ['20260817', '20260818'], + Open: ['100', '102'], + High: ['103', '106'], + Low: ['99', '101'], + Close: ['102', '105'], + Amount: ['1000', '1200'], + Volume: ['100', '120'], + ...overrides, + }; +} + +describe('市场详情 K 线映射', () => { + it('按交易日期升序返回有效点并保留可空成交字段', () => { + const series = createSeries({ + Date: ['20260818', '20260817', 'bad-date'], + Open: ['102', '100', '1'], + High: ['106', '103', '1'], + Low: ['101', '99', '1'], + Close: ['105', '102', '1'], + Amount: ['', '1000', '1'], + Volume: ['120', '100', '1'], + }); + + expect(mapDetailSeries(series, 120)).toEqual([ + { + time: '20260817', + open: 100, + high: 103, + low: 99, + close: 102, + amount: 1000, + volume: 100, + }, + { + time: '20260818', + open: 102, + high: 106, + low: 101, + close: 105, + amount: null, + volume: 120, + }, + ]); + }); + + it('过滤价格字段缺失或高低价不自洽的行', () => { + const series = createSeries({ + Date: ['20260816', '20260817', '20260818'], + Open: ['100', '', '102'], + High: ['103', '104', '101'], + Low: ['99', '100', '100'], + Close: ['102', '103', '105'], + Amount: ['900', '1000', '1200'], + Volume: ['90', '100', '120'], + }); + + expect(mapDetailSeries(series, 120)).toEqual([ + { + time: '20260816', + open: 100, + high: 103, + low: 99, + close: 102, + amount: 900, + volume: 90, + }, + ]); + }); + + it('只保留请求数量内的最近交易日并把数量限制在 120', () => { + const dates = Array.from({ length: 130 }, (_, index) => { + const date = new Date(Date.UTC(2026, 0, index + 1)); + return date.toISOString().slice(0, 10).replaceAll('-', ''); + }); + const values = dates.map((_, index) => String(index + 1)); + const series = createSeries({ + Date: dates, + Open: values, + High: values, + Low: values, + Close: values, + Amount: values, + Volume: values, + }); + + const points = mapDetailSeries(series, 999); + + expect(points).toHaveLength(120); + expect(points[0]?.time).toBe('20260111'); + expect(points.at(-1)?.time).toBe('20260510'); + }); + + it('使用最后两个有效交易日计算最新报价和涨跌幅', () => { + expect( + mapDetailQuote(createSeries(), { + code: '000001.SH', + name: '上证指数', + type: 'index', + }), + ).toEqual({ + price: 105, + changePercent: 2.94, + direction: 'up', + tradeDate: '20260818', + }); + }); + + it('没有相邻有效收盘价时保留价格但将涨跌幅置空', () => { + expect( + mapDetailQuote(createSeries({ Close: ['0', '105'] }), { + code: '881001.SH', + name: 'A股板块', + type: 'sector', + }), + ).toEqual({ + price: 105, + changePercent: null, + direction: 'flat', + tradeDate: '20260818', + }); + }); +}); diff --git a/server/apps/api/src/market-overview/detail/market-detail.mapper.ts b/server/apps/api/src/market-overview/detail/market-detail.mapper.ts new file mode 100644 index 0000000..122ed2e --- /dev/null +++ b/server/apps/api/src/market-overview/detail/market-detail.mapper.ts @@ -0,0 +1,111 @@ +import type { QuoteDirection } from '../market-overview.dto'; +import type { + DetailInstrumentType, + DetailQuoteDto, + DetailSeriesPointDto, +} from './market-detail.dto'; +import type { IndexDefinition, TdxKlineSeries } from '../tdx/tdx.types'; + +export type DetailInstrumentDefinition = IndexDefinition & { type: DetailInstrumentType }; + +const MAX_DETAIL_POINTS = 120; + +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 toOptionalNonNegativeNumber(value: string | undefined): number | null { + const parsed = toNumber(value); + return parsed !== null && parsed >= 0 ? 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 isValidOhlc(open: number, high: number, low: number, close: number): boolean { + return high >= Math.max(open, close, low) && low <= Math.min(open, close, high); +} + +function mapPoint(series: TdxKlineSeries, index: number): DetailSeriesPointDto | null { + const time = toTradeDate(series.Date[index]); + const open = toNumber(series.Open[index]); + const high = toNumber(series.High[index]); + const low = toNumber(series.Low[index]); + const close = toNumber(series.Close[index]); + if (time === null || open === null || high === null || low === null || close === null) { + return null; + } + if (!isValidOhlc(open, high, low, close)) return null; + return { + time, + open, + high, + low, + close, + amount: toOptionalNonNegativeNumber(series.Amount[index]), + volume: toOptionalNonNegativeNumber(series.Volume[index]), + }; +} + +/** 只映射详情图表需要的字段,过滤坏点并按交易日稳定排序,防止原始数组污染页面。 */ +export function mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPointDto[] { + const length = Math.min( + series.Date.length, + series.Open.length, + series.High.length, + series.Low.length, + series.Close.length, + ); + const byDate = new Map(); + for (let index = 0; index < length; index += 1) { + const point = mapPoint(series, index); + if (point) byDate.set(point.time, point); + } + const safeCount = Number.isSafeInteger(count) + ? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS) + : MAX_DETAIL_POINTS; + return [...byDate.values()] + .sort((left, right) => left.time.localeCompare(right.time)) + .slice(-safeCount); +} + +function mapDirection(changePercent: number | null): QuoteDirection { + if (changePercent === null || changePercent === 0) return 'flat'; + return changePercent > 0 ? 'up' : 'down'; +} + +/** 报价使用最后两个有效交易日;上一收盘为零时不计算涨跌幅,避免无意义的无穷值。 */ +export function mapDetailQuote( + series: TdxKlineSeries, + _definition: DetailInstrumentDefinition, +): DetailQuoteDto | null { + const points = mapDetailSeries(series, MAX_DETAIL_POINTS); + const latest = points.at(-1); + if (!latest) return null; + const previous = points.at(-2); + const changePercent = + previous && previous.close > 0 + ? Math.round(((latest.close - previous.close) / previous.close) * 100 * 100) / 100 + : null; + return { + price: latest.close, + changePercent, + direction: mapDirection(changePercent), + tradeDate: latest.time, + }; +} diff --git a/server/apps/api/src/market-overview/detail/market-detail.service.test.ts b/server/apps/api/src/market-overview/detail/market-detail.service.test.ts new file mode 100644 index 0000000..d5fc26d --- /dev/null +++ b/server/apps/api/src/market-overview/detail/market-detail.service.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TdxClientError } from '../tdx/tdx.client'; +import type { TdxClient } from '../tdx/tdx.client'; +import { MarketDetailService } from './market-detail.service'; + +function createKline(overrides: Record = {}) { + return { + Date: ['20260817', '20260818'], + Open: ['100', '102'], + High: ['103', '106'], + Low: ['99', '101'], + Close: ['102', '105'], + Amount: ['1000', '1200'], + Volume: ['100', '120'], + ...overrides, + }; +} + +describe('市场详情服务', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z')); + }); + + it('返回指数详情并限制 TDX 请求为固定周期和数量', async () => { + const client: TdxClient = { + request: vi.fn().mockResolvedValue({ + ErrorId: '0', + Value: { '000001.SH': createKline() }, + }), + }; + const service = new MarketDetailService(client); + + const response = await service.getIndexDetail('000001.SH', '1d', 120); + + expect(response).toMatchObject({ + source: 'tdx', + status: 'fresh', + data: { + instrument: { code: '000001.SH', name: '上证指数', type: 'index' }, + quote: { price: 105, changePercent: 2.94, tradeDate: '20260818' }, + availableViews: { kline: true, intraday: false }, + }, + }); + expect(client.request).toHaveBeenCalledWith('get_market_data', { + stock_list: ['000001.SH'], + count: 120, + dividend_type: 'none', + period: '1d', + }); + }); + + it('未知指数代码不触发 TDX 请求并返回不可用状态', async () => { + const client: TdxClient = { request: vi.fn() }; + const response = await new MarketDetailService(client).getIndexDetail('999999.SH', '1d', 120); + + expect(response.status).toBe('unavailable'); + expect(response.data).toBeNull(); + expect(response.warnings).toEqual(['指数代码不可用']); + expect(client.request).not.toHaveBeenCalled(); + }); + + it('先校验一级行业目录,再读取板块详情', async () => { + const client: TdxClient = { + request: vi + .fn() + .mockResolvedValueOnce({ ErrorId: '0', Value: [{ Code: '881001.SH', Name: '行业总览' }] }) + .mockResolvedValueOnce({ ErrorId: '0', Value: { '881001.SH': createKline() } }), + }; + + const response = await new MarketDetailService(client).getSectorDetail('881001.SH', '1d', 60); + + expect(response).toMatchObject({ + status: 'fresh', + data: { instrument: { code: '881001.SH', name: '行业总览', type: 'sector' } }, + }); + expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', { + market: '16', + list_type: 1, + }); + expect(client.request).toHaveBeenNthCalledWith(2, 'get_market_data', { + stock_list: ['881001.SH'], + count: 60, + dividend_type: 'none', + period: '1d', + }); + }); + + it('交易日期不是上海当前日期时标记 delayed 并保留数据', async () => { + const client: TdxClient = { + request: vi.fn().mockResolvedValue({ + ErrorId: '0', + Value: { '000001.SH': createKline({ Date: ['20260815', '20260817'] }) }, + }), + }; + + const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 10); + + expect(response.status).toBe('delayed'); + expect(response.warnings).toEqual(['指数交易日期为 20260817,非当前日期']); + expect(response.data?.series).toHaveLength(2); + }); + + it('必填字段坏点会保留有效 K 线并标记 partial', async () => { + const client: TdxClient = { + request: vi.fn().mockResolvedValue({ + ErrorId: '0', + Value: { + '000001.SH': createKline({ Open: ['100', ''], Close: ['102', '105'] }), + }, + }), + }; + + const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120); + + expect(response.status).toBe('partial'); + expect(response.data?.series).toHaveLength(1); + expect(response.warnings).toEqual(['指数部分 K 线字段不可用']); + }); + + it('TDX 超时只返回固定脱敏文案', async () => { + const client: TdxClient = { + request: vi.fn().mockRejectedValue(new TdxClientError('timeout', '原始超时细节')), + }; + + const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120); + + expect(response.status).toBe('unavailable'); + expect(response.data).toBeNull(); + expect(response.warnings).toEqual(['指数请求超时']); + expect(JSON.stringify(response)).not.toContain('原始超时细节'); + }); +}); diff --git a/server/apps/api/src/market-overview/detail/market-detail.service.ts b/server/apps/api/src/market-overview/detail/market-detail.service.ts new file mode 100644 index 0000000..75647b5 --- /dev/null +++ b/server/apps/api/src/market-overview/detail/market-detail.service.ts @@ -0,0 +1,169 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { TdxClient } from '../tdx/tdx.client'; +import { TdxClientError } from '../tdx/tdx.client'; +import { + CORE_INDEXES, + type IndexDefinition, + type TdxKlineSeries, + type TdxMarketDataResult, + type TdxStockListResult, +} from '../tdx/tdx.types'; +import { TDX_CLIENT_PORT, type MarketOverviewResponse } from '../market-overview.service'; +import type { DetailInstrumentDefinition } from './market-detail.mapper'; +import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper'; +import type { + DetailPeriod, + IndexDetailDataDto, + IndexDetailResponseDto, + SectorDetailDataDto, + SectorDetailResponseDto, +} from './market-detail.dto'; + +const MAX_DETAIL_POINTS = 120; + +function createSnapshotMeta(): Pick< + MarketOverviewResponse, + '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' }; +} + +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 normalizeCount(count: number): number { + return Number.isSafeInteger(count) ? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS) : 120; +} + +function createTradeDateWarning(subject: string, tradeDate: string, asOf: string): string | null { + return tradeDate === getShanghaiCalendarDate(asOf) + ? null + : `${subject}交易日期为 ${tradeDate},非当前日期`; +} + +function getSeries(result: TdxMarketDataResult, code: string): TdxKlineSeries | null { + const series = result.Value?.[code]; + return series && typeof series === 'object' ? series : null; +} + +@Injectable() +export class MarketDetailService { + constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {} + + async getIndexDetail( + code: string, + period: DetailPeriod, + count: number, + ): Promise { + const definition = CORE_INDEXES.find((item) => item.code === code); + if (!definition || period !== '1d') { + const meta = createSnapshotMeta(); + return { ...meta, status: 'unavailable', data: null, warnings: ['指数代码不可用'] }; + } + return this.loadDetail({ ...definition, type: 'index' }, period, count, '指数'); + } + + async getSectorDetail( + code: string, + period: DetailPeriod, + count: number, + ): Promise { + const meta = createSnapshotMeta(); + if (period !== '1d') + return { ...meta, status: 'unavailable', data: null, warnings: ['板块查询周期不可用'] }; + try { + const catalog = await this.tdxClient.request('get_stock_list', { + market: '16', + list_type: 1, + }); + const definition = catalog.Value?.find((item) => item.Code === code); + if (!definition) + return { ...meta, status: 'unavailable', data: null, warnings: ['板块代码不可用'] }; + return this.loadDetail( + { code: definition.Code, name: definition.Name, type: 'sector' }, + period, + count, + '板块', + meta, + ); + } catch (error) { + return { + ...meta, + status: 'unavailable', + data: null, + warnings: [this.toWarning(error, '板块')], + }; + } + } + + private async loadDetail( + definition: DetailInstrumentDefinition, + period: DetailPeriod, + count: number, + subject: '指数' | '板块', + inheritedMeta?: Pick, 'snapshotId' | 'asOf' | 'source'>, + ): Promise> { + const meta = inheritedMeta ?? createSnapshotMeta(); + const safeCount = normalizeCount(count); + try { + const result = await this.tdxClient.request('get_market_data', { + stock_list: [definition.code], + count: safeCount, + dividend_type: 'none', + period, + }); + const rawSeries = getSeries(result, definition.code); + const series = rawSeries ? mapDetailSeries(rawSeries, safeCount) : []; + const quote = rawSeries ? mapDetailQuote(rawSeries, definition) : null; + if (!rawSeries || !series.length || !quote) { + return { ...meta, status: 'unavailable', data: null, warnings: [`${subject}行情不可用`] }; + } + + const expectedRows = Math.min(safeCount, rawSeries.Date.length); + const isPartial = series.length < expectedRows; + const tradeDateWarning = createTradeDateWarning(subject, quote.tradeDate, meta.asOf); + const status = isPartial ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh'; + const data = { + instrument: { code: definition.code, name: definition.name, type: definition.type }, + quote, + series, + // 分时候选协议尚未通过能力探测,禁止在页面伪造该视图。 + availableViews: { kline: true, intraday: false }, + } as TData; + return { + ...meta, + status, + data, + warnings: isPartial + ? [`${subject}部分 K 线字段不可用`] + : tradeDateWarning + ? [tradeDateWarning] + : [], + }; + } catch (error) { + return { + ...meta, + status: 'unavailable', + data: null, + warnings: [this.toWarning(error, subject)], + }; + } + } + + 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}数据不可用`; + } +} diff --git a/server/apps/api/src/market-overview/market-overview.module.ts b/server/apps/api/src/market-overview/market-overview.module.ts index 5312ceb..cc981ca 100644 --- a/server/apps/api/src/market-overview/market-overview.module.ts +++ b/server/apps/api/src/market-overview/market-overview.module.ts @@ -7,6 +7,8 @@ import { ShortTermMarketController } from './short-term.controller'; import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service'; import { createAktoolsClient } from './aktools/aktools.client'; import { createTdxClient } from './tdx/tdx.client'; +import { MarketDetailController } from './detail/market-detail.controller'; +import { MarketDetailService } from './detail/market-detail.service'; @Module({}) export class MarketOverviewModule { @@ -14,9 +16,10 @@ export class MarketOverviewModule { static register(config: ApiRuntimeConfig): DynamicModule { return { module: MarketOverviewModule, - controllers: [MarketOverviewController, ShortTermMarketController], + controllers: [MarketOverviewController, MarketDetailController, ShortTermMarketController], providers: [ MarketOverviewService, + MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) }, ShortTermMarketService, { provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) }, diff --git a/server/apps/api/test/market-detail.e2e-spec.ts b/server/apps/api/test/market-detail.e2e-spec.ts new file mode 100644 index 0000000..cd113dc --- /dev/null +++ b/server/apps/api/test/market-detail.e2e-spec.ts @@ -0,0 +1,120 @@ +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MarketDetailController } from '../src/market-overview/detail/market-detail.controller'; +import { MarketDetailService } from '../src/market-overview/detail/market-detail.service'; +import { TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service'; + +const indexResponse = { + ErrorId: '0', + Value: { + '000001.SH': { + Date: ['20260817', '20260818'], + Open: ['100', '102'], + High: ['103', '106'], + Low: ['99', '101'], + Close: ['102', '105'], + Amount: ['1000', '1200'], + Volume: ['100', '120'], + }, + }, +}; + +describe('市场详情 HTTP API', () => { + let app: INestApplication | undefined; + + afterEach(async () => { + await app?.close(); + app = undefined; + }); + + it('返回指数详情并隐藏 TDX 原始字段', async () => { + const client = { request: vi.fn().mockResolvedValue(indexResponse) }; + const moduleRef = await Test.createTestingModule({ + controllers: [MarketDetailController], + providers: [MarketDetailService, { 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/000001.SH/detail?count=2') + .expect(200); + + expect(response.body).toMatchObject({ + source: 'tdx', + data: { + instrument: { code: '000001.SH', name: '上证指数', type: 'index' }, + series: [{ time: '20260817' }, { time: '20260818' }], + availableViews: { kline: true, intraday: false }, + }, + }); + expect(JSON.stringify(response.body)).not.toContain('ErrorId'); + }); + + it('拒绝越界数量和非法代码且不调用 TDX', async () => { + const client = { request: vi.fn() }; + const moduleRef = await Test.createTestingModule({ + controllers: [MarketDetailController], + providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api/v1'); + await app.init(); + + await request(app.getHttpServer()) + .get('/api/v1/market-overview/indices/not-a-code/detail') + .expect(400); + await request(app.getHttpServer()) + .get('/api/v1/market-overview/indices/000001.SH/detail?count=121') + .expect(400); + + expect(client.request).not.toHaveBeenCalled(); + }); + + it('板块详情先读目录再返回行业 K 线', async () => { + const client = { + request: vi + .fn() + .mockResolvedValueOnce({ + ErrorId: '0', + Value: [{ Code: '881001.SH', Name: '行业总览' }], + }) + .mockResolvedValueOnce({ + ErrorId: '0', + Value: { + '881001.SH': { + Date: ['20260817', '20260818'], + Open: ['100', '102'], + High: ['103', '106'], + Low: ['99', '101'], + Close: ['102', '105'], + Amount: ['1000', '1200'], + Volume: ['100', '120'], + }, + }, + }), + }; + const moduleRef = await Test.createTestingModule({ + controllers: [MarketDetailController], + providers: [MarketDetailService, { 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/881001.SH/detail') + .expect(200); + + expect(response.body.data.instrument).toEqual({ + code: '881001.SH', + name: '行业总览', + type: 'sector', + }); + expect(client.request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index 0b4a058..74e346c 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -40,6 +40,46 @@ "required": ["status", "detail"], "type": "object" }, + "DetailAvailableViewsDto": { + "properties": { + "intraday": { "type": "boolean" }, + "kline": { "type": "boolean" } + }, + "required": ["kline", "intraday"], + "type": "object" + }, + "DetailInstrumentDto": { + "properties": { + "code": { "type": "string" }, + "name": { "type": "string" }, + "type": { "enum": ["index", "sector"], "type": "string" } + }, + "required": ["code", "name", "type"], + "type": "object" + }, + "DetailQuoteDto": { + "properties": { + "changePercent": { "nullable": true, "type": "number" }, + "direction": { "enum": ["up", "down", "flat"], "type": "string" }, + "price": { "type": "number" }, + "tradeDate": { "example": "20260818", "type": "string" } + }, + "required": ["price", "direction", "tradeDate"], + "type": "object" + }, + "DetailSeriesPointDto": { + "properties": { + "amount": { "nullable": true, "type": "number" }, + "close": { "type": "number" }, + "high": { "type": "number" }, + "low": { "type": "number" }, + "open": { "type": "number" }, + "time": { "example": "20260818", "type": "string" }, + "volume": { "nullable": true, "type": "number" } + }, + "required": ["time", "open", "high", "low", "close"], + "type": "object" + }, "FundDataDto": { "properties": { "changePercent": { "type": "number" }, @@ -101,6 +141,40 @@ "required": ["status", "process", "checkedAt", "dependencies"], "type": "object" }, + "IndexDetailDataDto": { + "properties": { + "availableViews": { + "$ref": "#/components/schemas/DetailAvailableViewsDto" + }, + "instrument": { "$ref": "#/components/schemas/DetailInstrumentDto" }, + "quote": { "$ref": "#/components/schemas/DetailQuoteDto" }, + "series": { + "items": { "$ref": "#/components/schemas/DetailSeriesPointDto" }, + "type": "array" + } + }, + "required": ["instrument", "quote", "series", "availableViews"], + "type": "object" + }, + "IndexDetailResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/IndexDetailDataDto" }], + "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" + }, "IndexQuoteDto": { "properties": { "changePercent": { "nullable": true, "type": "number" }, @@ -152,6 +226,40 @@ "required": ["items"], "type": "object" }, + "SectorDetailDataDto": { + "properties": { + "availableViews": { + "$ref": "#/components/schemas/DetailAvailableViewsDto" + }, + "instrument": { "$ref": "#/components/schemas/DetailInstrumentDto" }, + "quote": { "$ref": "#/components/schemas/DetailQuoteDto" }, + "series": { + "items": { "$ref": "#/components/schemas/DetailSeriesPointDto" }, + "type": "array" + } + }, + "required": ["instrument", "quote", "series", "availableViews"], + "type": "object" + }, + "SectorDetailResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/SectorDetailDataDto" }], + "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" + }, "SectorQuoteDto": { "properties": { "changePercent": { "type": "number" }, @@ -401,6 +509,50 @@ "tags": ["market-overview"] } }, + "/api/v1/market-overview/indices/{code}/detail": { + "get": { + "operationId": "MarketDetailController_getIndexDetail", + "parameters": [ + { + "in": "query", + "name": "count", + "required": false, + "schema": { + "example": 120, + "maximum": 120, + "minimum": 1, + "type": "number" + } + }, + { + "in": "query", + "name": "period", + "required": false, + "schema": { "enum": ["1d"], "type": "string" } + }, + { + "description": "带市场后缀的指数代码", + "in": "path", + "name": "code", + "required": true, + "schema": { "example": "000001.SH" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexDetailResponseDto" + } + } + }, + "description": "" + } + }, + "tags": ["market-overview-detail"] + } + }, "/api/v1/market-overview/sectors": { "get": { "operationId": "MarketOverviewController_getSectors", @@ -418,6 +570,50 @@ "tags": ["market-overview"] } }, + "/api/v1/market-overview/sectors/{code}/detail": { + "get": { + "operationId": "MarketDetailController_getSectorDetail", + "parameters": [ + { + "in": "query", + "name": "count", + "required": false, + "schema": { + "example": 120, + "maximum": 120, + "minimum": 1, + "type": "number" + } + }, + { + "in": "query", + "name": "period", + "required": false, + "schema": { "enum": ["1d"], "type": "string" } + }, + { + "description": "带市场后缀的一级行业代码", + "in": "path", + "name": "code", + "required": true, + "schema": { "example": "881001.SH" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectorDetailResponseDto" + } + } + }, + "description": "" + } + }, + "tags": ["market-overview-detail"] + } + }, "/api/v1/market-overview/short-term/conclusion": { "get": { "operationId": "ShortTermMarketController_getConclusion", diff --git a/server/tools/export-openapi.ts b/server/tools/export-openapi.ts index 1e5beba..e9561ff 100644 --- a/server/tools/export-openapi.ts +++ b/server/tools/export-openapi.ts @@ -9,6 +9,7 @@ import { format } from 'prettier'; import { HealthController } from '../apps/api/src/health.controller'; import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller'; +import { MarketDetailController } from '../apps/api/src/market-overview/detail/market-detail.controller'; import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller'; import { HealthService, @@ -19,6 +20,7 @@ import { MarketOverviewService, TDX_CLIENT_PORT, } from '../apps/api/src/market-overview/market-overview.service'; +import { MarketDetailService } from '../apps/api/src/market-overview/detail/market-detail.service'; import { createAktoolsClient } from '../apps/api/src/market-overview/aktools/aktools.client'; import { AKTOOLS_CLIENT_PORT, @@ -27,10 +29,16 @@ import { import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client'; @Module({ - controllers: [HealthController, MarketOverviewController, ShortTermMarketController], + controllers: [ + HealthController, + MarketOverviewController, + MarketDetailController, + ShortTermMarketController, + ], providers: [ HealthService, MarketOverviewService, + MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } }, ShortTermMarketService, { diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index d4c704c..54bcf84 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -68,6 +68,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/market-overview/indices/{code}/detail': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketDetailController_getIndexDetail']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/market-overview/sectors': { parameters: { query?: never; @@ -84,6 +100,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/market-overview/sectors/{code}/detail': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketDetailController_getSectorDetail']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/market-overview/short-term/conclusion': { parameters: { query?: never; @@ -157,6 +189,34 @@ export interface components { /** @enum {string} */ status: 'healthy' | 'unavailable' | 'disabled'; }; + DetailAvailableViewsDto: { + intraday: boolean; + kline: boolean; + }; + DetailInstrumentDto: { + code: string; + name: string; + /** @enum {string} */ + type: 'index' | 'sector'; + }; + DetailQuoteDto: { + changePercent?: number | null; + /** @enum {string} */ + direction: 'up' | 'down' | 'flat'; + price: number; + /** @example 20260818 */ + tradeDate: string; + }; + DetailSeriesPointDto: { + amount?: number | null; + close: number; + high: number; + low: number; + open: number; + /** @example 20260818 */ + time: string; + volume?: number | null; + }; FundDataDto: { changePercent: number; currentAmount: number; @@ -194,6 +254,23 @@ export interface components { /** @enum {string} */ status: 'healthy' | 'unavailable'; }; + IndexDetailDataDto: { + availableViews: components['schemas']['DetailAvailableViewsDto']; + instrument: components['schemas']['DetailInstrumentDto']; + quote: components['schemas']['DetailQuoteDto']; + series: components['schemas']['DetailSeriesPointDto'][]; + }; + IndexDetailResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['IndexDetailDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; IndexQuoteDto: { changePercent?: number | null; code: string; @@ -220,6 +297,23 @@ export interface components { SectorDataDto: { items: components['schemas']['SectorQuoteDto'][]; }; + SectorDetailDataDto: { + availableViews: components['schemas']['DetailAvailableViewsDto']; + instrument: components['schemas']['DetailInstrumentDto']; + quote: components['schemas']['DetailQuoteDto']; + series: components['schemas']['DetailSeriesPointDto'][]; + }; + SectorDetailResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['SectorDetailDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; SectorQuoteDto: { changePercent: number; code: string; @@ -405,6 +499,31 @@ export interface operations { }; }; }; + MarketDetailController_getIndexDetail: { + parameters: { + query?: { + count?: number; + period?: '1d'; + }; + header?: never; + path: { + /** @description 带市场后缀的指数代码 */ + code: unknown; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['IndexDetailResponseDto']; + }; + }; + }; + }; MarketOverviewController_getSectors: { parameters: { query?: never; @@ -424,6 +543,31 @@ export interface operations { }; }; }; + MarketDetailController_getSectorDetail: { + parameters: { + query?: { + count?: number; + period?: '1d'; + }; + header?: never; + path: { + /** @description 带市场后缀的一级行业代码 */ + code: unknown; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SectorDetailResponseDto']; + }; + }; + }; + }; ShortTermMarketController_getConclusion: { parameters: { query?: never; diff --git a/web/src/api/market-overview/detail.test.ts b/web/src/api/market-overview/detail.test.ts new file mode 100644 index 0000000..cc3a962 --- /dev/null +++ b/web/src/api/market-overview/detail.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RequestClient } from '@/utils/request'; +import { createMarketDetailApi } from './detail'; + +describe('市场详情业务 API', () => { + it('使用独立详情路径和固定查询参数请求指数与板块', async () => { + const requestMock = vi.fn((config: Parameters[0]) => { + void config; + return Promise.resolve({}); + }); + const api = createMarketDetailApi(requestMock as unknown as RequestClient); + + await api.getIndexDetail('000001.SH', { period: '1d', count: 120 }); + await api.getSectorDetail('881001.SH', { period: '1d', count: 60 }); + + expect(requestMock.mock.calls.map(([config]) => config)).toEqual([ + { + method: 'get', + url: '/api/v1/market-overview/indices/000001.SH/detail', + params: { period: '1d', count: 120 }, + }, + { + method: 'get', + url: '/api/v1/market-overview/sectors/881001.SH/detail', + params: { period: '1d', count: 60 }, + }, + ]); + }); +}); diff --git a/web/src/api/market-overview/detail.ts b/web/src/api/market-overview/detail.ts new file mode 100644 index 0000000..157c0b3 --- /dev/null +++ b/web/src/api/market-overview/detail.ts @@ -0,0 +1,28 @@ +import request, { type RequestClient } from '@/utils/request'; +import type { components } from '@/api/generated/schema'; + +export type IndexDetailResponse = components['schemas']['IndexDetailResponseDto']; +export type SectorDetailResponse = components['schemas']['SectorDetailResponseDto']; +export type DetailQuery = { period: '1d'; count: number }; + +/** 详情请求只在 API 文件中拼接受控路径,页面和组件不感知 Axios 或服务端地址。 */ +export function createMarketDetailApi(requestClient: RequestClient = request) { + return { + getIndexDetail: (code: string, params: DetailQuery) => + requestClient({ + method: 'get', + url: `/api/v1/market-overview/indices/${encodeURIComponent(code)}/detail`, + params, + }), + getSectorDetail: (code: string, params: DetailQuery) => + requestClient({ + method: 'get', + url: `/api/v1/market-overview/sectors/${encodeURIComponent(code)}/detail`, + params, + }), + }; +} + +const marketDetailApi = createMarketDetailApi(); + +export default marketDetailApi; diff --git a/web/src/app/router.ts b/web/src/app/router.ts index 5e0622b..30f5870 100644 --- a/web/src/app/router.ts +++ b/web/src/app/router.ts @@ -6,6 +6,7 @@ import { } from 'vue-router'; import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue'; +import MarketDetailPage from '@/views/market-overview/detail/MarketDetailPage.vue'; const routes: RouteRecordRaw[] = [ { @@ -13,6 +14,18 @@ const routes: RouteRecordRaw[] = [ name: 'home', component: MarketOverviewPage, }, + { + path: '/market-overview/indices/:code', + name: 'market-index-detail', + component: MarketDetailPage, + meta: { detailType: 'index' }, + }, + { + path: '/market-overview/sectors/:code', + name: 'market-sector-detail', + component: MarketDetailPage, + meta: { detailType: 'sector' }, + }, ]; /** 创建可注入 history 的路由实例,使浏览器运行与单元测试共享同一路由表。 */ diff --git a/web/src/styles/index.scss b/web/src/styles/index.scss index e1d9692..1903ef5 100644 --- a/web/src/styles/index.scss +++ b/web/src/styles/index.scss @@ -174,6 +174,226 @@ textarea { opacity: 0.55; } +.market-detail-page { + display: grid; + gap: 16px; +} + +.detail-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; +} + +.quiet-button { + min-height: 32px; + padding: 0 10px; + color: #3f5667; + background: #fff; + border: 1px solid #ccd6de; + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.detail-toolbar-context, +.detail-chart-note { + color: #73808c; + font-size: 12px; +} + +.detail-hero-panel, +.detail-quote-panel, +.detail-chart-panel, +.detail-facts-panel, +.detail-state-panel { + background: #fff; + border: 1px solid #dbe1e6; + border-radius: 7px; + box-shadow: 0 3px 14px rgb(26 38 49 / 5%); +} + +.detail-hero-panel { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 24px; +} + +.detail-hero-panel h1 { + margin: 8px 0 4px; + color: #17212b; + font-size: clamp(24px, 4vw, 38px); + line-height: 1.15; +} + +.detail-hero-panel p { + margin: 0; + color: #73808c; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +.detail-meta { + display: grid; + align-content: start; + justify-items: end; + gap: 7px; + min-width: 220px; + color: #64717d; + font-size: 12px; + text-align: right; +} + +.detail-meta strong, +.detail-warning strong { + color: #177b70; + font-size: 12px; +} + +.detail-meta strong[data-status='delayed'], +.detail-meta strong[data-status='partial'], +.detail-meta strong[data-status='unavailable'], +.detail-warning strong { + color: #a45e1b; +} + +.detail-quote-panel { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; +} + +.detail-quote-panel > div { + display: grid; + gap: 8px; + min-height: 108px; + padding: 18px 20px; + background: #fff; +} + +.detail-quote-panel span { + color: #71808c; + font-size: 12px; +} + +.detail-quote-panel strong { + color: #1c2d3a; + font-size: 26px; + line-height: 1; +} + +.detail-quote-panel strong[data-direction='up'] { + color: #c2483f; +} + +.detail-quote-panel strong[data-direction='down'] { + color: #16806b; +} + +.detail-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.detail-section-heading h2 { + margin: 5px 0 0; + color: #17212b; + font-size: 17px; +} + +.detail-chart-panel, +.detail-facts-panel { + padding: 20px 22px; +} + +.detail-chart { + display: block; + width: 100%; + height: auto; + min-height: 180px; + margin-top: 16px; + background: #fbfcfd; + border: 1px solid #edf0f3; +} + +.detail-chart-axis { + stroke: #d9e0e5; + stroke-width: 1; +} + +.detail-chart-line { + fill: none; + stroke: #167d82; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.detail-chart-empty { + display: grid; + min-height: 180px; + margin-top: 16px; + place-items: center; + color: #73808c; + background: #fbfcfd; + border: 1px dashed #cfd8df; + font-size: 13px; +} + +.detail-chart-boundary { + margin: 10px 0 0; + color: #8b6d48; + font-size: 12px; +} + +.detail-warning, +.detail-state-panel { + display: flex; + align-items: center; + gap: 10px; + min-height: 48px; + padding: 12px 16px; + color: #6e5b40; + background: #fff8ed; + border: 1px solid #f0d7b5; + border-radius: 7px; + font-size: 12px; +} + +.detail-state-panel { + min-height: 150px; + justify-content: center; + flex-direction: column; + color: #667581; + background: #fff; + border-color: #dbe1e6; + text-align: center; +} + +.detail-state-panel strong { + color: #273744; + font-size: 16px; +} + +.detail-state-warning { + color: #7c613e; + background: #fff8ed; + border-color: #f0d7b5; +} + +.detail-facts-panel p { + max-width: 720px; + margin: 14px 0 0; + color: #53626d; + font-size: 13px; + line-height: 1.7; +} + .overview-conclusion, .overview-panel { background: #fff; @@ -686,4 +906,28 @@ textarea { .index-grid { grid-template-columns: 1fr; } + + .detail-hero-panel { + flex-direction: column; + padding: 20px; + } + + .detail-meta { + justify-items: start; + min-width: 0; + text-align: left; + } + + .detail-quote-panel { + grid-template-columns: 1fr; + } + + .detail-quote-panel > div { + min-height: 82px; + } + + .detail-chart-panel, + .detail-facts-panel { + padding: 18px 16px; + } } diff --git a/web/src/views/market-overview/MarketOverviewPage.vue b/web/src/views/market-overview/MarketOverviewPage.vue index dae8aa8..322bda2 100644 --- a/web/src/views/market-overview/MarketOverviewPage.vue +++ b/web/src/views/market-overview/MarketOverviewPage.vue @@ -1,5 +1,6 @@ @@ -82,7 +87,7 @@ function openDetail(label: string) { @@ -91,7 +96,7 @@ function openDetail(label: string) { { + it('在有序 K 线输入下渲染固定尺寸 SVG 路径', () => { + const wrapper = mount(MarketDetailChart, { + props: { + series: [ + { + time: '20260817', + open: 100, + high: 103, + low: 99, + close: 102, + amount: 1000, + volume: 100, + }, + { + time: '20260818', + open: 102, + high: 106, + low: 101, + close: 105, + amount: 1200, + volume: 120, + }, + ], + availableViews: { kline: true, intraday: false }, + }, + }); + + expect(wrapper.get('svg').attributes('viewBox')).toBe('0 0 720 260'); + expect(wrapper.get('path').attributes('d')).toMatch(/^M /); + expect(wrapper.text()).toContain('日 K 线'); + }); + + it('无图表数据时显示边界说明而不是空白画布', () => { + const wrapper = mount(MarketDetailChart, { + props: { series: [], availableViews: { kline: false, intraday: false } }, + }); + + expect(wrapper.find('svg').exists()).toBe(false); + expect(wrapper.text()).toContain('当前没有可用的 K 线数据'); + }); +}); diff --git a/web/src/views/market-overview/detail/MarketDetailChart.vue b/web/src/views/market-overview/detail/MarketDetailChart.vue new file mode 100644 index 0000000..21ee04a --- /dev/null +++ b/web/src/views/market-overview/detail/MarketDetailChart.vue @@ -0,0 +1,54 @@ + + + diff --git a/web/src/views/market-overview/detail/MarketDetailPage.test.ts b/web/src/views/market-overview/detail/MarketDetailPage.test.ts new file mode 100644 index 0000000..796512e --- /dev/null +++ b/web/src/views/market-overview/detail/MarketDetailPage.test.ts @@ -0,0 +1,115 @@ +import { mount } from '@vue/test-utils'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import MarketDetailPage from './MarketDetailPage.vue'; + +const { getIndexDetail, getSectorDetail } = vi.hoisted(() => ({ + getIndexDetail: vi.fn(), + getSectorDetail: vi.fn(), +})); + +vi.mock('@/api/market-overview/detail', () => ({ + default: { getIndexDetail, getSectorDetail }, +})); + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/market-overview/indices/:code', + component: MarketDetailPage, + meta: { detailType: 'index' }, + }, + ], + }); +} + +const freshResponse = { + snapshotId: '20260818-030000', + asOf: '2026-08-18T00:00:00.000Z', + source: 'tdx' as const, + status: 'fresh' as const, + warnings: [], + data: { + instrument: { code: '000001.SH', name: '上证指数', type: 'index' as const }, + quote: { price: 3380, changePercent: 0.89, direction: 'up' as const, tradeDate: '20260818' }, + series: [ + { + time: '20260817', + open: 3350, + high: 3385, + low: 3340, + close: 3350, + amount: 100, + volume: 1000, + }, + { + time: '20260818', + open: 3370, + high: 3400, + low: 3360, + close: 3380, + amount: 120, + volume: 1100, + }, + ], + availableViews: { kline: true, intraday: false }, + }, +}; + +describe('市场详情页', () => { + beforeEach(() => { + getIndexDetail.mockReset(); + getSectorDetail.mockReset(); + }); + + it('按路由加载指数详情并显示上海时间、K 线和分时边界', async () => { + getIndexDetail.mockResolvedValue(freshResponse); + const router = createTestRouter(); + await router.push('/market-overview/indices/000001.SH'); + await router.isReady(); + + const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } }); + await vi.waitFor(() => + expect(getIndexDetail).toHaveBeenCalledWith('000001.SH', { period: '1d', count: 120 }), + ); + await vi.waitFor(() => expect(wrapper.text()).toContain('上证指数')); + + expect(wrapper.text()).toContain('上证指数'); + expect(wrapper.text()).toContain('2026-08-18 08:00:00'); + expect(wrapper.text()).toContain('日 K 线'); + expect(wrapper.text()).toContain('当前数据源未提供分时数据'); + }); + + it('路由代码格式非法时不调用 API 并显示不可用边界', async () => { + const router = createTestRouter(); + await router.push('/market-overview/indices/not-a-code'); + await router.isReady(); + + const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } }); + + expect(getIndexDetail).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(wrapper.text()).toContain('详情代码不可用')); + }); + + it('TDX 返回 unavailable 时显示 warning 而不是伪造图表', async () => { + getIndexDetail.mockResolvedValue({ + ...freshResponse, + status: 'unavailable', + data: null, + warnings: ['指数数据不可用'], + }); + const router = createTestRouter(); + await router.push('/market-overview/indices/000001.SH'); + await router.isReady(); + + const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } }); + await vi.waitFor(() => expect(getIndexDetail).toHaveBeenCalled()); + await vi.waitFor(() => expect(wrapper.text()).toContain('指数数据不可用')); + + expect(wrapper.text()).toContain('指数数据不可用'); + expect(wrapper.find('svg').exists()).toBe(false); + }); +}); diff --git a/web/src/views/market-overview/detail/MarketDetailPage.vue b/web/src/views/market-overview/detail/MarketDetailPage.vue new file mode 100644 index 0000000..3d3f733 --- /dev/null +++ b/web/src/views/market-overview/detail/MarketDetailPage.vue @@ -0,0 +1,149 @@ + + + diff --git a/web/src/views/market-overview/detail/market-detail.ts b/web/src/views/market-overview/detail/market-detail.ts new file mode 100644 index 0000000..be6e925 --- /dev/null +++ b/web/src/views/market-overview/detail/market-detail.ts @@ -0,0 +1,35 @@ +import type { components } from '@/api/generated/schema'; + +export type MarketDetailType = 'index' | 'sector'; +export type MarketDetailResponse = + | components['schemas']['IndexDetailResponseDto'] + | components['schemas']['SectorDetailResponseDto']; +export type MarketDetailData = NonNullable; +export type MarketDetailSeries = MarketDetailData['series']; + +export function isMarketDetailType(value: unknown): value is MarketDetailType { + return value === 'index' || value === 'sector'; +} + +export function isMarketCode(value: unknown): value is string { + return typeof value === 'string' && /^\d{6}\.(SH|SZ|BJ)$/.test(value); +} + +export function formatTradeDate(value: string | undefined): string { + return value && /^\d{8}$/.test(value) + ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` + : '—'; +} + +export function formatPercent(value: number | null): string { + return value === null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`; +} + +export function statusLabel(status: MarketDetailResponse['status']): string { + return { + fresh: '数据新鲜', + delayed: '交易日延迟', + partial: '部分数据', + unavailable: '数据不可用', + }[status]; +}