基金转换 T-9:API 模型 + 网关分派(HTTP 层 convert 走通)+ 展示位数口径修复

一、T-9 本体:HTTP 层 convert 端到端走通

- api/simulate.py:TradeRequest 三型字段分池(subscribe/redeem → product_id+amount;
  convert → from/to_product_id + qty + 可选 client_request_id)+ model_validator 分支校验;
  未知类型放行给网关抛 400(保住既有 purchase → 400 断言);model_dump(exclude_none=True);
  PROCESSING → 202;异常捕获由 except LookupError 收窄为 except NotFoundError
  (原写法把 KeyError 这类编程错误静默转成 404,实测掩盖 convert 分支真实诊断)。
- gateway/trade_gateway.py:移除 convert 显式拒绝,新增 _submit_convert 分派
  (只做参数映射 + 仓储装配);convert 不写 trade_request 审计(审计归 convert_service)。
- utils/response.py:错误体合入 exc.extra(TOO_MANY_LOTS 的 batch_count/max_lots);
  既有 ApiError 无 extra 属性 → 老错误体逐字节不变。
- utils/trace.py + main.py:正则收敛单点定义。执行期发现 trace.py 与 main.py 各有一份
  内容完全相同的白名单副本 —— S4 要防的「漂移」其实已经发生,现将常量上移 trace.py
  成公开 HEADER_ID_PATTERN(同时解决 main→simulate 反向导入成环)。

二、展示位数口径修复(执行期发现 → 联网核验 → 修复 → 文档订正)

发现:同一逻辑响应两种写法 —— 首次 "53456.95" vs 幂等重放 "53456.9500",数值相等、字符串不等。
根因不是 T-7 写错,是契约缺位:§2.5 只规定「金额/份额 2 位」,净值、费率、申请份额的
回显位数根本没定义 → 实现只能 str(Decimal) 原样出网 → 位数随数据来源漂移。

修复:convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口 ——
金额/份额 2 位、净值/费率/份额尾差 4 位;响应 + 审计 summary + 异常日志共用该出口;
原 _s() 全部替换。首次路径幂等(除 requested_qty/actual_qty/lot[].qty 由 4 位补齐 2 位外不变)。

依据(2026-09-10 联网核验 7 家管理人公告):金额/份额「四舍五入保留至小数点后两位」;
「申请转换份额精确到小数点后两位」;净值保留 4 位第 5 位四舍五入(中欧/国泰公告由 3 位提高至 4 位);
费率以百分比 2 位表示。已知不统一:易方达 ETF 场外份额取整数位、南方基金取截断 → 取主流口径
并记入 PRD 已知差异(未来接真实 TA 需按基金合同配置化)。

三、文档订正

- PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增按字段分类的规格表 + 外部依据);
  §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5
  「计算与对外展示按 2 位」自相矛盾,属漏改);字段类型约定补「位数不自由 + 两条路径须逐字节一致」。
- 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」,无结构变更。

四、验证

- 新增 tests/test_convert_integration.py(8 条真 MySQL 端到端,CNV-TEST-/TRD-TEST- 前缀隔离):
  折算与 PRD §5.3 逐项吻合、两条流水同组、持仓与批次如实变动、明细 completed + 审计、
  幂等重试不产生第二组、跨主体 400、未知类型 400,以及
  「首次与重放逐字段逐字节相等」+「展示位数规格」两条新闸门。
- test_trade_gateway.py +17(11 条错误码映射全表参数化 · 202 · 200 透传 · 不写 trade_request 审计)。
- test_integration_risk.py:R15 处置 —— 端到端已迁入新文件,原槽位改造为
  test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。
- pytest -q → 697 passed / 3 skipped(基线 672 +25,零回归)。
- 真库复跑:T-6 24/24 · T-7 35/35 · T-8 31/31;calc_convert_demo.py 15/15。
- 突变验证 4 组:关掉 convert 分派 → 21 条红;关掉错误体 extra 展开 → 精准 1 条;
  关掉 client_request_id 正则 → 精准 1 条;关掉 _q() 展示量化 → 2 条红
  (assert '50000.0000' == '50000' 直接复现原缺陷)。均已恢复。
This commit is contained in:
2026-09-10 18:00:20 +08:00
parent 7ad1c8204d
commit d0097d6004
15 changed files with 1177 additions and 108 deletions
@@ -124,17 +124,17 @@ T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算
| **第 3 批 · 事务与编排** | **T-6** ✅ | `convert_core_repository.apply_convert`(阶段一单事务)—— **真库 24/24** | T-1/T-3 | **高(方言 + 并发)** |
| | **T-7** ✅ | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5)—— **17 用例 + 真库 35/35** | T-2~T-6 | **高(关键路径)** |
| **第 4 批 · 引擎与网关** | **T-8** ✅ | `_amount_view` + `engine.process_convert_event` + `alert_service.events` —— **2026-09-10 完成(15 用例 + 真库 31/31)** | 无(可与 T-2 并行) | 中 |
| | T-9 | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 | T-7 | 中 |
| | **T-9** ✅ | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 + **展示位数口径修复** —— **2026-09-10 完成(新增集成 8 条 / 11 条错误码映射)** | T-7 | 中 |
| | T-11 | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 | T-8 | 中 |
| **第 5 批 · 高风险专项** | **T-10** | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` | T-3(排在 T-7 后) | **最高(打穿 510)** |
| **第 6 批 · 补偿** | T-12 | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 | 低 |
| **第 7 批 · 收口** | T-13 | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 | 全部 | 中 |
**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**T-7 已通,T-9 已解锁**)
**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**T-7 已通;T-9 已完成**)
**并行组 A**:T-3 / T-4 / T-5(✅ 全部完成)
**并行组 B**:T-8 全程可与 T-2 之后任意任务并行(✅ 已完成)
**硬门禁**:`T-0` 与 `T-0b` **双双绿**才允许启动 T-1 及之后(T-0 用例 = `test_db.py::test_core_holding_columns`)
**测试基线**:**672**(2026-09-10 T-8 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → **672**)→ 剩余任务(T-9~T-13)预计再加 **25~55** → **700~730**(估算)
**测试基线**:**697**(2026-09-10 T-9 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → 672 → **697**)→ 剩余任务(T-10~T-13)预计再加 **10~40** → **707~737**(估算)
---
@@ -1090,14 +1090,85 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
- **不改路由**:仍为 `POST /api/simulate/trade`(F-12/§1.2,路由清单断言零影响)
**DoD**
- [ ] `tests/test_trade_gateway.py` 的 4 处断言按 §12 清单改写完毕(R3/R4/R5 + R7 补批次断言)
- [ ] `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit` 按 §12 **R15** 处置(改写为端到端走通,或迁入 `test_convert_integration.py`)
- [ ] `test_convert_integration.py`:真 MySQL `CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合(~6 条)
- [ ] 错误码映射 8 条各有断言(含 `CROSS_ENTITY_NOT_SUPPORTED`,验收 14)
- [ ] 未知 trade_type(如 `purchase`)仍 400
- [x] `tests/test_trade_gateway.py` 的 4 处断言按 §12 清单改写完毕(**R3/R4/R5 已改;R7 归 T-10**,见执行记录裁定③)
- [x] `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit` 按 §12 **R15** 处置(端到端迁入 `test_convert_integration.py`;原槽位改造为 `test_invalid_type_400_and_no_new_trade_audit`,用 `purchase` 触发以保住「校验失败不落审计」不变量)
- [x] `test_convert_integration.py`:真 MySQL `CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合 + **重放逐字节一致** + **展示位数规格**(**8 条**)
- [x] 错误码映射 **11 条**各有断言(架构 §8.3 全表 + `extra` 展开;含 `CROSS_ENTITY_NOT_SUPPORTED`,验收 14)
- [x] 未知 trade_type(如 `purchase`)仍 400
**依赖**:T-7
**执行记录(2026-09-10 · 已完成)**
*改码 5 处*
| 文件 | 动作 |
| --- | --- |
| `app/utils/trace.py` | 【改】`_HEADER_ID_PATTERN` → **公开 `HEADER_ID_PATTERN`**(单点定义,三处共用) |
| `app/main.py` | 【改】删掉第 44 行那份**重复副本** `_TRACE_ID_PATTERN` 与 `import re`,改为 import 上面的常量 |
| `app/utils/response.py` | 【改】`_api_error_handler` 合入 `exc.extra`(`getattr` 取,既有 `ApiError` 无此属性 → 错误体逐字节不变);`error_body` 返回类型放宽 `dict[str, Any]` |
| `app/gateway/trade_gateway.py` | 【改】移除 convert 显式拒绝;新增 `_submit_convert` 分派(参数映射 + 仓储装配,模块级符号作 monkeypatch 注入点);`UnsupportedTradeType` 文案含 convert |
| `app/api/simulate.py` | 【改】`TradeRequest` 三型字段分池 + `@model_validator` 分支校验(`client_request_id` 走同一白名单);`model_dump(exclude_none=True)`;`except LookupError` **收窄为 `except NotFoundError`**;`PROCESSING` → 202 |
*实施级裁定 3 条(与计划原文有出入,逐条给理由)*
1. **正则落点:上移 `trace.py`,不是「复用 `main.py:44`」**。计划关键实现点写的是复用 `main.py` 的 `_TRACE_ID_PATTERN`,
但 `app.main` → `app.api.simulate` 是**单向导入链**,反向 import 成环。上移到 `trace.py`(叶子工具模块、零 app 依赖)
反而更彻底地满足 S4 的**真实意图**——执行期发现 `trace.py:16` 早有**内容完全相同**的一份副本,
`main.py:44` 是第二份,「两套白名单漂移」**其实已经发生**;现收敛为单点定义。
⚠️ 连带影响:§12 **R14 写的「本次不动 `main.py`」在此被突破**(中间件**注册顺序未动**,
`test_main.py` 中间件守卫用例仍绿,R14 的实际保护对象未受影响)。
2. **`gateway_repository.insert_trade` 增 `qty`/`convert_group_id`:裁定为不需要**。
convert 的 `core_trade` 写路径在 `convert_core_repository.apply_convert`(阶段一单事务),
与普通申赎的 `insert_trade` 是两条独立入口,故该文件**零改动**(`git diff` 可证)。
3. **R7「补批次断言」归 T-10**。R7 的失效前提是 T-10 改 `trade_gateway` 主流程,
T-9 不动该主流程,故 R7 计入 T-10 结项(§8 已列)。
同理 **R16 零改动通过**(`test_redeem_accepted_without_alert` 未改,全量绿可证)。
4. **`lot_count` 与 `batch_count` 是两个不同位置的字段名,均**按文档保留,**不是笔误**:
成功响应(PRD §5.3 权威命名)用 **`lot_count`**;错误体 `TOO_MANY_LOTS`(架构 §8.3 契约)用 **`batch_count` + `max_lots`**。
本节「关键实现点」把两者并列为 `batch_count`/`max_lots` 属**口语化表述**(把成功响应的批次数字段也写成了 `batch_count`)。
实现与前缀保持一致 —— **成功看 PRD、报错看架构 §8.3**,勿「统一」成同一个名字(会同时打破两份契约)。
*实施级发现 1 条 —— **已按用户指令修复(2026-09-10,同日)***
**幂等重放响应的数值位数与首次响应不一致**(发现 → 上报 → 修复,全程留痕):
- **现象**:同一逻辑响应出现两种写法 —— 首次 `"53456.95"`(2 位)vs 重放 `"53456.9500"`(4 位),**数值相等、字符串不等**。
影响字段:`requested_qty` / `actual_qty` / `out_amount` / `redeem_fee` / `in_amount` / `in_qty` / `lot_breakdown[]`。
- **根因(不是 T-7 写错,是契约缺位)**:首次响应走 `calc` 纯函数(已按 2 位量化),重放响应由
`_rebuild_quote` 从 `core_trade` / `core_convert_lot_detail`(**`DECIMAL(18,4)`**)重建后**直接 `str()` 出去**。
§2.5 只规定了「金额/份额 2 位」,**净值、费率、申请份额的回显位数根本没有定义** →
实现只能把 `Decimal` 原样转串 → **位数随数据来源漂移**。
- **修复(贴近现实业务,联网核验后定口径)**:新增 **`convert_service._q(value, unit)` +
`_D2` / `_D4` 两个规格常量**作为**对外唯一出口**,按字段语义量化:
**金额/份额 2 位**(公告「转出金额/转入份额四舍五入保留至小数点后两位」;「申请转换份额精确到小数点后两位」)、
**净值/费率/份额尾差 4 位**(净值保留 4 位第 5 位四舍五入,中欧/国泰公告由 3 位提高至 4 位;费率以百分比 2 位表示)。
响应、审计 `summary`、异常日志**共用同一出口**。首次路径的输入已由 `calc` 按同规格量化 → **幂等**,
除 `requested_qty`/`actual_qty`/`lot[].qty` 由 4 位补齐至 2 位外**逐字节不变**。
- **外部依据(2026-09-10 联网核验,7 家管理人公告)**:中银 / 人保 / 浦银安盛 / 中欧 / 南方 / 申万菱信 / 易方达。
**已知不统一**:易方达(ETF 场外)份额取**整数位**、南方基金取**截断**(非四舍五入)→ 本期按主流口径,
并已在 PRD §2.5.2 / §10 记为已知差异(未来接真实 TA 需按基金合同配置化)。
- **文档订正**:PRD → **v0.9.2**(§2.5 拆 2.5.1 计算精度 / **2.5.2 展示位数**,新增分类规格表 + 外部依据;
§5.3 示例份额字段 4 位 → 2 位,**修掉示例与 §2.5 的自相矛盾**;§5.3 字段类型约定补「位数不自由」)
· 架构 → **v1.0.1**(§1 原则 11 补「`str()` 前必须按 §2.5.2 量化」)。
- **验证**:集成测试改用**逐字段逐字节比对**(`test_convert_idempotent_retry_returns_byte_identical_response`)
+ 新增**位数规格断言**(`test_convert_response_field_scales`);`pytest -q` → **697 passed / 3 skipped(零回归)**;
T-7 真库脚本复跑 **35/35**;`calc_convert_demo.py` **15/15**;
**突变验证**:把 `_q()` 的量化去掉 → **2 条红**,`assert '50000.0000' == '50000'` 直接复现修复前现象。
*测试与验证*
- `pytest -q` → **697 passed / 3 skipped**(基线 672 **+25**:`test_trade_gateway.py` +17、新增 `test_convert_integration.py` +8,**零回归**)
- **突变验证(3 组,防假绿)**:① 关掉 convert 分派(改回拒绝)→ **21 条变红**(7 集成 + 11 错误码 + 3 其余);
② 关掉错误体 `extra` 展开 → **精准 1 条**(`TOO_MANY_LOTS` 体);
③ 关掉 `client_request_id` 正则校验 → **精准 1 条**(`..._bad_client_request_id_returns_422`)。三处均已恢复,`grep MUTATION-TEST app/` 为空。
- **真库验证载体 = `tests/test_convert_integration.py` 本身**(真 MySQL、非脚本)。
理由:T-9 **不新增任何 SQL / 不涉方言语义**(不触碰 SET 求值顺序、DECIMAL 写入精度、gap lock),
故无需另写 `scripts/dev/verify_convert_*.py`;集成测试经 `ensure_risk_demo_ready()`
在无 MySQL 环境整模块 skip,**不炸 CI**(与 `test_integration_risk.py` 同约定)。
- 集成测试隔离三条件(自建 `CNVTEST` 种子 + `CNV-TEST-`/`TRD-TEST-` 前缀 + teardown 全清)已落在文件 docstring,
规避了 `risk_demo_env` **不还原 `core_share_lot`/`core_holding`** 的已知坑。
---
### 7.3 T-11 · 工具汇总去重 + SQL 求和去重
@@ -1244,7 +1315,7 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点
| **R12** | `app/service/risk/rules.py:78-83` `_eligible` | `trade_type in ('subscribe','redeem')` | convert 两条流水若是 `convert` 类型 → 被过滤 | **不改 `_eligible`**;改为**约束写入端**(R-b:流水写 `redeem`/`subscribe`) |
| **R13** | `app/repository/core_ro.py:390/432` | `trade_type IN ('subscribe','redeem')` 硬写 | —(不需改:convert 两条流水本就是这两类) | **不改**(`:432` 的求和在 R-d 中另加 gid 条件) |
| **R14** | `app/main.py:88/94` 中间件顺序 | audit 先注册 / trace 后注册(T-202 守卫) | 本次不动 `main.py` | **零改动**(若因 T-0b 误改 `main.py`,`test_main.py` 的守卫用例会红 → 属自发现) |
| **R15** | `tests/test_integration_risk.py:426-437` `test_convert_400_and_no_new_trade_audit` | convert → **400** + `BAD_REQUEST` + **不落审计**(`after["n"] == before["n"]`) | T-9 移除 convert 拒绝后,**状态码与审计计数双重失败** | **改写为端到端走通用例**(`convert → 200` + 断言落一条 `trade_accepted` 审计),或**整体迁入** `test_convert_integration.py` 后从本文件删除。<br>※ 本条为**第一轮审核补入**(初版 §12 漏列该真 MySQL 集成用例) |
| **R15** | `tests/test_integration_risk.py:426-437` `test_convert_400_and_no_new_trade_audit` | convert → **400** + `BAD_REQUEST` + **不落审计**(`after["n"] == before["n"]`) | T-9 移除 convert 拒绝后,**状态码与审计计数双重失败** | **改写为端到端走通用例**(`convert → 200` + 断言落一条 `convert_accepted` 审计;<br>※ 计划初稿写的 `trade_accepted` 是误写,**实际决策字面量为 `convert_accepted`**,T-9 执行期以代码为准更正),或**整体迁入** `test_convert_integration.py` 后从本文件删除。<br>※ 本条为**第一轮审核补入**(初版 §12 漏列该真 MySQL 集成用例)。<br>✅ **T-9 实际处置**:端到端已迁入 `test_convert_integration.py`;原槽位**未删除**,改造为 `test_invalid_type_400_and_no_new_trade_audit`(改用 `purchase` 触发),以保住原用例真正保护的不变量「校验失败不落审计」 |
| **R16** | `tests/test_trade_gateway.py:145-154` `test_redeem_accepted_without_alert` | redeem 1000 元放行、`core_trade` 计 1 条、无预警 | T-10 给 redeem 加 FIFO 扣减后,`env` **无持仓无批次**(F-13)→ 无 R-c(1) 降级则直接失败 | 由 **R-c(1)「redeem 既无批次也无持仓 → warning 跳过扣减」** 保住,**该用例零改动**;其**真实扣减路径**由 `test_share_lot.py` 自建种子覆盖(R-c(2))<br>※ 本条为**第一轮审核补入** |
---
@@ -1,6 +1,6 @@
# 架构设计说明书 · 基金转换(convert)交易
> 版本:**v1.0(PRD v0.9 配套 · 已过独立评审)** · 日期:2026-09-10
> 版本:**v1.0.1(PRD v0.9.2 配套 · 已过独立评审;v1.0.1 仅补 §1 原则 11 展示位数,无结构变更)** · 日期:2026-09-10
> 上游:`docs/PRD/PRD-基金转换交易.md`(**v0.9** —— v0.8 经 33 条外审闭环;v0.9 为架构评审回填 2 处契约)
> 分支:`risk-control-agent` · 关联:`docs/项目框架设计/架构设计-风控模块.md`
> 技术选型不变:FastAPI + SQLAlchemy Core(text SQL) + MySQL 双库 + Redis + LangGraph + DeepSeek。
@@ -122,6 +122,11 @@
舍入误差**在基金资产列支**,响应可选回传 `rounding_diff`(**可正可负**)。
⚠️ Python `Decimal.quantize()` 默认 `ROUND_HALF_EVEN`(银行家舍入),**必须显式传 `ROUND_HALF_UP`**(D·红线)
11. **响应金额全部 `str()` 化**:路由未声明 `response_model` 时 FastAPI `jsonable_encoder` 会把 `Decimal` 转 float(精度风险)。convert 分支返回前全部显式 `str()`
- **(v1.0.1 补充)`str()` 之前必须按 §2.5.2 展示位数量化**:库内一律 `DECIMAL(18,4)`,
直接 `str()` 会让**位数随数据来源漂移** —— 按公式算出的值是 2 位、按库值回读的值是 4 位,
同一逻辑响应出现 `"53456.95"` 与 `"53456.9500"` 两种写法(实测缺陷)。
展示规格**单点定义**在 `convert_service._q()`(金额/份额 2 位、净值/费率/尾差 4 位),
响应、审计 summary、日志**共用同一出口**,保证「首发」与「幂等重放」逐字节一致。
12. **单次转换最多跨 `convert_batch_max_lots` 个批次**(默认 200):超限返回 400 `TOO_MANY_LOTS`,防事务膨胀。
一期**不做自动分拆**(一次请求 = 一个 `convert_group_id` = 一个 core 事务);自动拆成多笔留二期(评审 R4)
13. **Core 读写账号物理分离**(主架构评审 **C1** / §三 交叉点 · **已拍板「按真实项目走」**):