Files
group_xinghuo_jinrong/app/api/simulate.py
T
zhanghongyu_0626 647c07062e feat(trade): Enhance trade processing and context handling in customer service
- Introduced `pending_trade` handling in the chat API to manage trade requests more effectively.
- Updated the `submit_trade_api` to allow advisors to access customer trades based on assigned roles.
- Added new methods in `GatewayRepository` for managing core holdings during trade subscriptions and redemptions.
- Implemented context-aware trade dialogue management in the customer service layer to improve user experience during multi-turn interactions.
- Enhanced the tool service to support trade actions and suitability checks, ensuring accurate processing of user requests.

This update significantly improves the trade interaction flow, providing a more robust and user-friendly experience for customers engaging in trading activities.
2026-09-13 17:45:04 +08:00

169 lines
8.4 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`:`product_id` + **`amount`**(申购按金额申报,行为不变);
- `redeem`:`product_id` + **`qty`**(**份额申报** · D26/R-6,PRD Q15「金额申购、
份额赎回」行业铁律;T-9 起不再接受 amount 反算 —— 变更前的 `amount` 入参
属 v1.0 项目侧简化,已废弃);
- `convert`:`from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`),
交网关分派至 `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 改分派后
新链路不再产生,保留分支以兼容过渡期)。
"""
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.core_ro import CoreReadOnlyRepository
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")
#: 按**金额**申报的普通类型(申购 · FR-C16)。
AMOUNT_TRADE_TYPES = ("subscribe",)
#: 按**份额**申报的类型(赎回 · D26/R-6;convert 亦为份额,但字段池不同)。
QTY_TRADE_TYPES = ("redeem",)
def _repo() -> RiskRepository:
"""审计仓储(deny 留痕用;测试 monkeypatch 点)。"""
return RiskRepository()
class TradeRequest(BaseModel):
"""模拟交易请求(架构 §8.1)。
**字段按 `trade_type` 分池**(三型互斥,由 `_check_by_trade_type` 把关):
- `subscribe` → `product_id` + `amount`(**金额申购**);
- `redeem` → `product_id` + `qty`(**份额赎回** · D26,T-9 起);
- `convert` → `from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`)。
`amount` / `qty` 的 `gt=0` **必须保留**(R5):pydantic v2 对 `None` 不触发
数值校验、对 `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 必填")
# ── subscribe 专用(金额申购)──
amount: Decimal | None = Field(None, gt=0, description="申购金额(元);仅 subscribe 必填")
# ── redeem / 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="申请份额;redeem(份额赎回)与 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 AMOUNT_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 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)"
)
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 + 受理回执**(PRD §5.3.1,含 `convert_group_id`
与 `status='accepted'`);适当性阻断等其余业务结果(含 `blocked=true`)**均 200**。
见模块 docstring「两类 202」。
"""
core_ro = CoreReadOnlyRepository()
allowed = auth.has_role("risk_demo") or (
auth.is_customer() and auth.customer_id == req.customer_id
)
if not allowed and auth.has_role("advisor"):
allowed = core_ro.is_advisor_assigned(auth.actor_id, req.customer_id)
if not allowed:
deny(
auth, "AUTH_403_ROLE", _repo(),
customer_id=req.customer_id, message="risk_demo, owner customer, or assigned advisor",
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 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)
return result