一、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' 直接复现原缺陷)。均已恢复。
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""交易网关服务(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}
|