Files
group_xinghuo_jinrong/app/api/simulate.py
T
GaoYiYuan_0626 d0097d6004 基金转换 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' 直接复现原缺陷)。均已恢复。
2026-09-10 18:00:20 +08:00

134 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模拟交易网关路由(PRD FR-1 · 薄路由,不含业务)。
鉴权:`Depends(get_auth_context)`(B6 回挂,评审 P2-2)——一期接受
risk_demo 演示账号或客户本人(auth.customer_id == 请求 customer_id,
PRD FR-1 §鉴权);越权经 deps.deny 审计后 403。T-01 后工厂内部换 JWT。
trace:main 中间件贯通(B7),响应头 X-Trace-Id 回写;service 层 ensure_trace
仍兜底脚本/测试直调场景。
挂载:main.py include(B7)。错误体统一 ApiError → 手册 §10 结构(挂账④)。
T-9 起支持三型 trade_type(架构 §8.1):
- `subscribe` / `redeem`:`product_id` + `amount`(原有行为不变);
- `convert`:`from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`),
交网关分派至 `convert_service`;**未抢到执行权 → 202**,
其余业务结果(含 `blocked=true`)一律 200(HTTP 语义与 FR-1 一致)。
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, model_validator
from app.api.deps import AuthContext, deny, get_auth_context
from app.gateway.trade_gateway import UnsupportedTradeType, submit_trade
from app.repository.risk_repository import RiskRepository
from app.service.convert.convert_service import PROCESSING
from app.utils.exceptions import ApiError, NotFoundError
from app.utils.trace import HEADER_ID_PATTERN
router = APIRouter(prefix="/api/simulate", tags=["simulate"])
#: 走「网关直写 core_trade」的普通申赎类型;convert 有独立分支(T-9)。
SIMPLE_TRADE_TYPES = ("subscribe", "redeem")
def _repo() -> RiskRepository:
"""审计仓储(deny 留痕用;测试 monkeypatch 点)。"""
return RiskRepository()
class TradeRequest(BaseModel):
"""模拟交易请求(架构 §8.1)。
**字段按 `trade_type` 分池**(三型互斥,由 `_check_by_trade_type` 把关):
- `subscribe` / `redeem` → `product_id` + `amount`;
- `convert` → `from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`)。
`amount` / `qty` 的 `gt=0` **必须保留**(R5):pydantic v2 对 `None` 不触发
数值校验、对 `0`/负数触发 → 「`amount=0` → 422」用例零改动通过(2.13.4 已实测)。
"""
customer_id: str = Field(..., min_length=1)
trade_type: str = Field(..., max_length=16, description="subscribe | redeem | convert")
# ── subscribe / redeem 专用 ──
product_id: str | None = Field(None, description="subscribe/redeem 必填")
amount: Decimal | None = Field(None, gt=0, description="交易金额(元),必须为正数")
# ── convert 专用 ──
from_product_id: str | None = Field(None, description="convert 必填:转出基金")
to_product_id: str | None = Field(None, description="convert 必填:转入基金")
qty: Decimal | None = Field(None, gt=0, description="convert 必填:申请转出份额")
client_request_id: str | None = Field(
None,
description="幂等键;白名单与 X-Trace-Id 共用同一份(S4,避免两套正则漂移)",
)
@model_validator(mode="after")
def _check_by_trade_type(self) -> TradeRequest:
"""按 `trade_type` 分支校验(架构 §8.1)。
**未知类型不拦**:交网关抛 `UnsupportedTradeType` → 400,保持既有
`purchase → 400` 断言(R3)。校验失败抛 `ValueError` → FastAPI 统一 422。
"""
if self.trade_type in SIMPLE_TRADE_TYPES:
if self.product_id is None or self.amount is None:
raise ValueError(f"{self.trade_type} 需同时提供 product_id 与 amount")
elif self.trade_type == "convert":
if (
self.from_product_id is None
or self.to_product_id is None
or self.qty is None
):
raise ValueError("convert 需同时提供 from_product_id / to_product_id / qty")
if self.client_request_id is not None and not HEADER_ID_PATTERN.fullmatch(
self.client_request_id
):
raise ValueError(
"client_request_id 仅允许字母、数字与 . _ - ,长度 1~64(同 X-Trace-Id 白名单)"
)
return self
@router.post("/trade")
def submit_trade_api(
req: TradeRequest, auth: AuthContext = Depends(get_auth_context)
) -> Any:
"""模拟交易(FR-1):适当性阻断或放行 + 引擎判定,返回 blocked + trade_id。
convert(T-9):未抢到执行权 → **202** + `{convert_group_id, status}`;
其余一切业务结果(含 `blocked=true`、`engine_error=true`)**均 200**。
"""
if not (
auth.has_role("risk_demo")
or (auth.is_customer() and auth.customer_id == req.customer_id)
):
deny(
auth, "AUTH_403_ROLE", _repo(),
customer_id=req.customer_id, message="risk_demo or owner customer only",
agent_type="platform", # 网关越权与放行审计同口径(复审 P3)
)
try:
# exclude_none:convert 请求不带 product_id/amount,申赎请求不带 from/to/qty,
# 与改造前 `model_dump()` 的输出逐键等价(新字段全为 None 时被剔除)。
result = submit_trade(
req.model_dump(exclude_none=True), actor_id=auth.actor_id
)
except UnsupportedTradeType as exc:
raise ApiError(400, "BAD_REQUEST", str(exc)) from exc
except NotFoundError as exc:
# B6 评审 P3-5 的收敛锚点:服务层抛的是 `NotFoundError`(精确 404)。
# T-9 由 `except LookupError` 收窄至此 —— `KeyError` 同为 `LookupError`
# 子类,原写法会把服务层「字段缺失」这类**编程错误静默转成 404**
# (本次实测:convert 分支 KeyError 被吞成 NOT_FOUND,掩盖真实诊断)。
raise ApiError(404, "NOT_FOUND", str(exc)) from exc
if result.get("status") == PROCESSING:
# 架构 §8.3:并发同键请求正在执行 → 202 + {convert_group_id, status: "processing"}
return JSONResponse(status_code=202, content=result)
return result