"""模拟交易网关路由(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