一、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' 直接复现原缺陷)。均已恢复。
134 lines
6.2 KiB
Python
134 lines
6.2 KiB
Python
"""模拟交易网关路由(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
|