2026-09-06 18:05:44 +08:00
|
|
|
|
"""模拟交易网关路由(PRD FR-1 · 薄路由,不含业务)。
|
|
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
鉴权:`Depends(get_auth_context)`(B6 回挂,评审 P2-2)——一期接受
|
|
|
|
|
|
risk_demo 演示账号或客户本人(auth.customer_id == 请求 customer_id,
|
|
|
|
|
|
PRD FR-1 §鉴权);越权经 deps.deny 审计后 403。T-01 后工厂内部换 JWT。
|
2026-09-06 20:48:50 +08:00
|
|
|
|
trace:main 中间件贯通(B7),响应头 X-Trace-Id 回写;service 层 ensure_trace
|
|
|
|
|
|
仍兜底脚本/测试直调场景。
|
2026-09-10 18:00:20 +08:00
|
|
|
|
挂载:main.py include(B7)。错误体统一 ApiError → 手册 §10 结构(挂账④)。
|
|
|
|
|
|
|
|
|
|
|
|
T-9 起支持三型 trade_type(架构 §8.1):
|
2026-09-11 20:37:45 +08:00
|
|
|
|
- `subscribe`:`product_id` + **`amount`**(申购按金额申报,行为不变);
|
|
|
|
|
|
- `redeem`:`product_id` + **`qty`**(**份额申报** · D26/R-6,PRD Q15「金额申购、
|
|
|
|
|
|
份额赎回」行业铁律;T-9 起不再接受 amount 反算 —— 变更前的 `amount` 入参
|
|
|
|
|
|
属 v1.0 项目侧简化,已废弃);
|
2026-09-10 18:00:20 +08:00
|
|
|
|
- `convert`:`from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`),
|
2026-09-11 20:37:45 +08:00
|
|
|
|
交网关分派至 `convert_service.accept_convert`(T 日**受理**,不扣份额不折算);
|
|
|
|
|
|
**受理成功 → 202 + 受理回执(PRD §5.3.1)**;适当性阻断 → 200 + `blocked=true`
|
|
|
|
|
|
(HTTP 语义与 FR-1 一致)。
|
|
|
|
|
|
|
|
|
|
|
|
**两类 202 需分辨**(PRD §5.3.1 表「重复提交(幂等命中 / 在飞)」):
|
|
|
|
|
|
`status='accepted'` = 受理成功(`accepted=true`,含幂等命中返回既有单);
|
|
|
|
|
|
`status='processing'` = 并发执行中(v1.0 实时链路遗留形态,T-9 改分派后
|
|
|
|
|
|
新链路不再产生,保留分支以兼容过渡期)。
|
2026-09-06 18:05:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from decimal import Decimal
|
2026-09-10 18:00:20 +08:00
|
|
|
|
from typing import Any
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
2026-09-06 20:48:50 +08:00
|
|
|
|
from fastapi import APIRouter, Depends
|
2026-09-10 18:00:20 +08:00
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
from app.api.deps import AuthContext, deny, get_auth_context
|
2026-09-06 18:05:44 +08:00
|
|
|
|
from app.gateway.trade_gateway import UnsupportedTradeType, submit_trade
|
2026-09-06 18:58:48 +08:00
|
|
|
|
from app.repository.risk_repository import RiskRepository
|
2026-09-10 18:00:20 +08:00
|
|
|
|
from app.service.convert.convert_service import PROCESSING
|
|
|
|
|
|
from app.utils.exceptions import ApiError, NotFoundError
|
|
|
|
|
|
from app.utils.trace import HEADER_ID_PATTERN
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/simulate", tags=["simulate"])
|
|
|
|
|
|
|
2026-09-10 18:00:20 +08:00
|
|
|
|
#: 走「网关直写 core_trade」的普通申赎类型;convert 有独立分支(T-9)。
|
|
|
|
|
|
SIMPLE_TRADE_TYPES = ("subscribe", "redeem")
|
2026-09-11 20:37:45 +08:00
|
|
|
|
#: 按**金额**申报的普通类型(申购 · FR-C16)。
|
|
|
|
|
|
AMOUNT_TRADE_TYPES = ("subscribe",)
|
|
|
|
|
|
#: 按**份额**申报的类型(赎回 · D26/R-6;convert 亦为份额,但字段池不同)。
|
|
|
|
|
|
QTY_TRADE_TYPES = ("redeem",)
|
2026-09-10 18:00:20 +08:00
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
def _repo() -> RiskRepository:
|
|
|
|
|
|
"""审计仓储(deny 留痕用;测试 monkeypatch 点)。"""
|
|
|
|
|
|
return RiskRepository()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
class TradeRequest(BaseModel):
|
2026-09-10 18:00:20 +08:00
|
|
|
|
"""模拟交易请求(架构 §8.1)。
|
|
|
|
|
|
|
|
|
|
|
|
**字段按 `trade_type` 分池**(三型互斥,由 `_check_by_trade_type` 把关):
|
2026-09-11 20:37:45 +08:00
|
|
|
|
- `subscribe` → `product_id` + `amount`(**金额申购**);
|
|
|
|
|
|
- `redeem` → `product_id` + `qty`(**份额赎回** · D26,T-9 起);
|
2026-09-10 18:00:20 +08:00
|
|
|
|
- `convert` → `from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`)。
|
|
|
|
|
|
|
|
|
|
|
|
`amount` / `qty` 的 `gt=0` **必须保留**(R5):pydantic v2 对 `None` 不触发
|
2026-09-11 20:37:45 +08:00
|
|
|
|
数值校验、对 `0`/负数触发 → 「非正值 → 422」用例零改动通过(2.13.4 已实测)。
|
2026-09-10 18:00:20 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-06 18:05:44 +08:00
|
|
|
|
customer_id: str = Field(..., min_length=1)
|
2026-09-10 18:00:20 +08:00
|
|
|
|
trade_type: str = Field(..., max_length=16, description="subscribe | redeem | convert")
|
|
|
|
|
|
|
2026-09-11 20:37:45 +08:00
|
|
|
|
# ── subscribe / redeem 共用 ──
|
2026-09-10 18:00:20 +08:00
|
|
|
|
product_id: str | None = Field(None, description="subscribe/redeem 必填")
|
|
|
|
|
|
|
2026-09-11 20:37:45 +08:00
|
|
|
|
# ── subscribe 专用(金额申购)──
|
|
|
|
|
|
amount: Decimal | None = Field(None, gt=0, description="申购金额(元);仅 subscribe 必填")
|
|
|
|
|
|
|
|
|
|
|
|
# ── redeem / convert 专用(份额申报)──
|
2026-09-10 18:00:20 +08:00
|
|
|
|
from_product_id: str | None = Field(None, description="convert 必填:转出基金")
|
|
|
|
|
|
to_product_id: str | None = Field(None, description="convert 必填:转入基金")
|
2026-09-11 20:37:45 +08:00
|
|
|
|
qty: Decimal | None = Field(
|
|
|
|
|
|
None, gt=0, description="申请份额;redeem(份额赎回)与 convert 必填"
|
|
|
|
|
|
)
|
2026-09-10 18:00:20 +08:00
|
|
|
|
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。
|
|
|
|
|
|
"""
|
2026-09-11 20:37:45 +08:00
|
|
|
|
if self.trade_type in AMOUNT_TRADE_TYPES:
|
2026-09-10 18:00:20 +08:00
|
|
|
|
if self.product_id is None or self.amount is None:
|
|
|
|
|
|
raise ValueError(f"{self.trade_type} 需同时提供 product_id 与 amount")
|
2026-09-11 20:37:45 +08:00
|
|
|
|
elif self.trade_type in QTY_TRADE_TYPES:
|
|
|
|
|
|
# D26/R-6:赎回按**份额**申报(行业铁律「金额申购、份额赎回」)——
|
|
|
|
|
|
# 缺 qty 即 422,**不再**退回按 amount 反算份额的 v1.0 简化口径。
|
|
|
|
|
|
if self.product_id is None or self.qty is None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"redeem 需同时提供 product_id 与 qty(份额申报 · D26/R-6)"
|
|
|
|
|
|
)
|
2026-09-10 18:00:20 +08:00
|
|
|
|
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
|
2026-09-06 18:05:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/trade")
|
2026-09-10 18:00:20 +08:00
|
|
|
|
def submit_trade_api(
|
|
|
|
|
|
req: TradeRequest, auth: AuthContext = Depends(get_auth_context)
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
"""模拟交易(FR-1):适当性阻断或放行 + 引擎判定,返回 blocked + trade_id。
|
|
|
|
|
|
|
2026-09-11 20:37:45 +08:00
|
|
|
|
convert(T-9):**受理成功 → 202 + 受理回执**(PRD §5.3.1,含 `convert_group_id`
|
|
|
|
|
|
与 `status='accepted'`);适当性阻断等其余业务结果(含 `blocked=true`)**均 200**。
|
|
|
|
|
|
见模块 docstring「两类 202」。
|
2026-09-10 18:00:20 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not (
|
|
|
|
|
|
auth.has_role("risk_demo")
|
|
|
|
|
|
or (auth.is_customer() and auth.customer_id == req.customer_id)
|
|
|
|
|
|
):
|
2026-09-06 18:58:48 +08:00
|
|
|
|
deny(
|
|
|
|
|
|
auth, "AUTH_403_ROLE", _repo(),
|
|
|
|
|
|
customer_id=req.customer_id, message="risk_demo or owner customer only",
|
2026-09-06 19:21:15 +08:00
|
|
|
|
agent_type="platform", # 网关越权与放行审计同口径(复审 P3)
|
2026-09-06 18:58:48 +08:00
|
|
|
|
)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
try:
|
2026-09-10 18:00:20 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
)
|
2026-09-06 18:05:44 +08:00
|
|
|
|
except UnsupportedTradeType as exc:
|
2026-09-06 20:48:50 +08:00
|
|
|
|
raise ApiError(400, "BAD_REQUEST", str(exc)) from exc
|
2026-09-10 18:00:20 +08:00
|
|
|
|
except NotFoundError as exc:
|
|
|
|
|
|
# B6 评审 P3-5 的收敛锚点:服务层抛的是 `NotFoundError`(精确 404)。
|
|
|
|
|
|
# T-9 由 `except LookupError` 收窄至此 —— `KeyError` 同为 `LookupError`
|
|
|
|
|
|
# 子类,原写法会把服务层「字段缺失」这类**编程错误静默转成 404**
|
|
|
|
|
|
# (本次实测:convert 分支 KeyError 被吞成 NOT_FOUND,掩盖真实诊断)。
|
2026-09-06 20:48:50 +08:00
|
|
|
|
raise ApiError(404, "NOT_FOUND", str(exc)) from exc
|
2026-09-10 18:00:20 +08:00
|
|
|
|
|
2026-09-11 20:37:45 +08:00
|
|
|
|
if req.trade_type == "convert":
|
|
|
|
|
|
# 两类 202 分辨(PRD §5.3.1):
|
|
|
|
|
|
# ① status='processing' → 并发执行中(v1.0 实时链路遗留,T-9 新链路不再产生);
|
|
|
|
|
|
# ② accepted=True(status='accepted' 或幂等命中既有单)→ 受理成功。
|
|
|
|
|
|
# 阻断(blocked=true)不落 202 —— 它不是"已受理",走 200 + blocked 语义。
|
|
|
|
|
|
if result.get("status") == PROCESSING or result.get("accepted") is True:
|
|
|
|
|
|
return JSONResponse(status_code=202, content=result)
|
2026-09-10 18:00:20 +08:00
|
|
|
|
return result
|
2026-09-11 20:37:45 +08:00
|
|
|
|
|