"""交易网关服务(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}