Files
group_xinghuo_jinrong/app/gateway/trade_gateway.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

271 lines
10 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 · 架构 §3.1)。
`submit_trade` 为唯一入口:**按 `trade_type` 分派**——
- `subscribe` / `redeem`:参数校验 → suitability_check(FR-2,落校验日志;
不匹配→ suitability 预警单 + 阻断响应,交易不落 core_trade)→ 匹配 →
INSERT core_trade → 同步调规则引擎(FR-3)→ 返回 blocked + trade_id +
触发规则。阻断/放行全量审计(agent_type='platform',FR-1 §6);
- `convert`(T-9 起走通):交 `convert_service.convert_fund` 八步编排
(含转入端适当性、幂等、三阶段、阶段 1.5)。**审计由该服务落
`convert_request`**,本层不重复写 `trade_request`;未抢到执行权时
透传 `{status: "processing"}`,由路由层回 202(架构 §8.3)。
未知 `trade_type` 属参数校验失败(400),不落审计(PRD 审计口径仅阻断/放行)。
引擎异常兜底(架构 §5.3):交易已成立(core_trade 已提交),审计
decision='risk_engine_error' + logger.exception,响应带 engine_error=true
供 B9a rebuild_alerts 按 trade_id 补偿重放。
鉴权归路由层(T-01/B6 的 get_auth_context:risk_demo 或客户本人);
trace 由调用方中间件贯通,本层 ensure_trace 兜底(脚本/测试直调场景)。
"""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal
from typing import Any, Callable
from uuid import uuid4
from app.gateway.convert_core_repository import ConvertCoreRepository
from app.gateway.gateway_repository import GatewayRepository
from app.repository.convert_repository import ConvertRepository
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.convert.convert_service import convert_fund
from app.service.risk.engine import process_trade_event
from app.service.risk.rules import RiskThresholds
from app.service.risk.alert_service import record_suitability_alert
from app.service.suitability import SuitabilityResult, suitability_check
from app.utils.trace import ensure_trace
logger = logging.getLogger(__name__)
SUPPORTED_TRADE_TYPES = ("subscribe", "redeem")
CONVERT = "convert"
ADVICE = "请联系持证投资顾问"
RECORDED_NOTICE = "本次请求已记录"
class UnsupportedTradeType(ValueError):
"""trade_type 非法(未知类型兜底拒绝,PRD FR-1;convert 自 T-9 起走通)。"""
def _new_trade_id(now: datetime) -> str:
return f"TRD-{now:%Y%m%d}-{uuid4().hex[:8].upper()}"
def _audit(
repo: RiskRepository,
*,
decision: str,
trade_id: str,
req: dict[str, Any],
rule_id: str | None = None,
detail: dict[str, Any] | None = None,
actor_id: str | None = None,
) -> None:
from app.utils.trace import current_trace, new_trace
repo.insert_audit_log(
{
"trace_id": current_trace() or new_trace(),
"event_type": "trade_request",
"agent_type": "platform",
"actor_id": actor_id or "SYSTEM", # C6 透传:代理人发起交易归属发起人;缺省 SYSTEM 保持现有测试/脚本零改动
"customer_id": req.get("customer_id"),
"rule_id": rule_id,
"input_summary": {
"trade_id": trade_id,
"product_id": req.get("product_id"),
"trade_type": req.get("trade_type"),
"amount": str(req.get("amount")),
**(detail or {}),
},
"decision": decision,
"risk_score": None,
"handler_id": None,
"handler_result": None,
"handler_comment": None,
}
)
def _submit_convert(
req: dict[str, Any],
*,
core: CoreReadOnlyRepository,
repo: RiskRepository,
thresholds: RiskThresholds,
actor_id: str | None,
now: datetime | None,
) -> dict[str, Any]:
"""convert 分派(T-9 · 架构 §3.1/§8.3):只做参数映射 + 仓储装配。
**职责边界**:八步编排(①产品 ②份额 ③折算 ④转入端适当性 ⑤占位
⑥阶段一 ⑦阶段二 ⑧阶段 1.5)与 `convert_request` 审计**全部**在
`convert_service.convert_fund` 内 —— 网关不重复校验、不重复审计、
不重复出单(否则一次转换会被记成 `trade_request` + `convert_request` 两条)。
`ConvertRepository` / `ConvertCoreRepository` **按模块级符号引用**
(与 `CoreReadOnlyRepository` 同款),测试经
`monkeypatch.setattr(tg, "ConvertRepository", ...)` 注入 sqlite 实例。
返回:走通 → PRD §5.3 响应体(Decimal 已全部 str);未抢到执行权 →
`{"status": "processing", "convert_group_id": None}`(路由层转 202)。
"""
return convert_fund(
{
"customer_id": req["customer_id"],
"from_product_id": req["from_product_id"],
"to_product_id": req["to_product_id"],
"qty": req["qty"],
"client_request_id": req.get("client_request_id"),
},
core_ro=core,
risk_repo=repo,
convert_repo=ConvertRepository(),
core_writer=ConvertCoreRepository(),
thresholds=thresholds,
now=now,
actor_id=actor_id,
)
def submit_trade(
req: dict[str, Any],
core_ro: CoreReadOnlyRepository | None = None,
risk_repo: RiskRepository | None = None,
gateway_repo: GatewayRepository | None = None,
thresholds: RiskThresholds | None = None,
now: datetime | None = None,
trade_id_factory: Callable[[datetime], str] | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
"""处理一笔模拟交易请求(PRD FR-1 流程 ①~⑤)。
req:{customer_id, product_id, trade_type, amount};amount 转 Decimal。
trade_id_factory:测试注入点(架构 §7 约定集成测试交易用 TRD-TEST- 前缀,
B8 teardown 按前缀清理;缺省 TRD-{date}-{uuid8})。
返回 FR-1 ⑤ 响应体:阻断 {blocked, trade_id, block_reason, reasons, advice,
notice};放行 {blocked, trade_id, triggered_rules, alert_ids, aml_hit}
(引擎异常时附 engine_error=true)。
"""
core = core_ro or CoreReadOnlyRepository()
repo = risk_repo or RiskRepository()
th = thresholds or RiskThresholds.from_settings()
ensure_trace()
trade_type = str(req.get("trade_type", ""))
if trade_type == CONVERT:
# convert 分派(T-9):不构造 GatewayRepository —— convert 的 core_trade
# 写路径在 `convert_core_repository.apply_convert`(阶段一单事务),
# 与普通申赎的 `gateway_repository.insert_trade` 是两条独立入口。
return _submit_convert(
req, core=core, repo=repo, thresholds=th, actor_id=actor_id, now=now
)
if trade_type not in SUPPORTED_TRADE_TYPES:
raise UnsupportedTradeType(
f"不支持的交易类型: {trade_type}(仅 subscribe/redeem/convert)"
)
writer = gateway_repo or GatewayRepository()
now = now or datetime.now()
trade_id = (trade_id_factory or _new_trade_id)(now)
amount = Decimal(str(req["amount"]))
traded_at = now
result: SuitabilityResult = suitability_check(
req["customer_id"], req["product_id"], core_ro=core, risk_repo=repo,
check_source="r02_trade", actor_id="svc-trade-suitability", request_ref=trade_id,
)
if result.blocked:
record_suitability_alert(
{
"trade_id": trade_id,
"customer_id": req["customer_id"],
"product_id": req["product_id"],
"trade_type": trade_type,
"amount": amount,
"traded_at": traded_at,
},
rule_id=result.rule_id,
block_reason=result.block_reason,
risk_repo=repo,
)
_audit(
repo,
decision="suitability_blocked",
trade_id=trade_id,
req=req,
rule_id=result.rule_id,
detail={
"block_reason": result.block_reason,
"block_response_code": result.block_response_code,
"reasons": list(result.reasons),
},
)
return {
"blocked": True,
"trade_id": trade_id,
"match_result": result.match_result,
"mismatch_type": result.mismatch_type,
"requires_disclosure": result.requires_disclosure,
"needs_branch_confirm": result.needs_branch_confirm,
"block_reason": result.block_reason,
"block_response_code": result.block_response_code,
"rule_refs": result.rule_refs,
"reasons": list(result.reasons),
"advice": ADVICE,
"notice": RECORDED_NOTICE,
}
writer.insert_trade(
trade_id, req["customer_id"], req["product_id"], trade_type, amount, traded_at
)
try:
engine_result = process_trade_event(
{
"trade_id": trade_id,
"customer_id": req["customer_id"],
"product_id": req["product_id"],
"trade_type": trade_type,
"amount": amount,
"trade_status": "confirmed",
"traded_at": traded_at,
},
core_ro=core,
risk_repo=repo,
thresholds=th,
)
except Exception:
# 交易已成立(core_trade 已提交):留审计与日志供 B9a rebuild_alerts 补偿
logger.exception("risk engine failed after trade accepted: %s", trade_id)
_audit(
repo,
decision="risk_engine_error",
trade_id=trade_id,
req=req,
actor_id=actor_id,
detail={"error_stage": "process_trade_event"},
)
return {
"blocked": False,
"trade_id": trade_id,
"triggered_rules": [],
"alert_ids": [],
"aml_hit": False,
"engine_error": True,
}
_audit(
repo,
decision="trade_accepted",
trade_id=trade_id,
req=req,
actor_id=actor_id,
detail=dict(engine_result), # 全量输出(评审 P2-3)
)
return {"blocked": False, "trade_id": trade_id, **engine_result}