Files
group_xinghuo_jinrong/tests/test_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

436 lines
17 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.
"""trade_gateway 集成测试(B5 · FR-1:convert 400 / 阻断不落 trade / 放行贯通引擎)。
服务层直测三路径 + TestClient 验 HTTP 语义(sqlite 全套表,驱动差异由引擎层
_normalize_trades 兜底)。API 层经 monkeypatch 注入 sqlite 仓储。
"""
from datetime import datetime, timedelta
from decimal import Decimal
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import text
from _ddl import create_sqlite_engine, seed_suitability_matrix
from app.api.simulate import router as simulate_router
from app.gateway import trade_gateway as tg
from app.gateway.gateway_repository import GatewayRepository
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.service.convert.errors import (
BelowMinQty,
CrossEntityNotSupported,
FeeRuleMissing,
IdempotencyUnavailable,
InsufficientShares,
LotConflict,
NavNotReady,
ProductNotRedeemable,
ProductNotSubscribable,
SameProduct,
TooManyLots,
)
from app.service.risk import alert_service
from app.service.risk.profile_l3 import AML_PENDING_TAG
from app.utils.response import register_error_handlers
class FakePublisher:
def __init__(self):
self.messages = []
self.deletes = []
def publish(self, channel, payload):
self.messages.append((channel, payload))
def delete(self, *keys):
self.deletes.append(keys)
@pytest.fixture()
def env():
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
seed_suitability_matrix(engine) # AL-05:check_suitability 以矩阵表为 L0 权威
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES"
" ('CUST-1001', '客户·王**', 28, 1), ('CUST-3001', '客户·孙**', 45, 1)"
)
)
conn.execute(
text(
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) VALUES"
" ('CUST-1001', 'C1', :t, :exp), ('CUST-3001', 'C3', :t, :exp)"
),
{"t": datetime.now() - timedelta(days=30), "exp": datetime.now() + timedelta(days=300)},
)
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type) VALUES"
" ('PROD-161725', '科技成长主题', 'R4', 'mixed'),"
" ('PROD-510300', '沪深300指数', 'R3', 'index')"
)
)
core = CoreReadOnlyRepository(engine=engine)
repo = RiskRepository(engine=engine)
writer = GatewayRepository(engine=engine)
pub = FakePublisher()
alert_service.set_publisher(pub)
yield core, repo, writer, pub, engine
alert_service.set_publisher(None)
engine.dispose()
def _req(customer="CUST-1001", product="PROD-161725", ttype="subscribe", amount="100000"):
return {
"customer_id": customer,
"product_id": product,
"trade_type": ttype,
"amount": amount,
}
def _convert_req(
customer="CUST-3001",
frm="PROD-510300",
to="PROD-161725",
qty="1000",
**extra,
):
"""convert 请求体(T-9 字段池):from/to/qty 三件套 + 可选幂等键。"""
return {
"customer_id": customer,
"trade_type": "convert",
"from_product_id": frm,
"to_product_id": to,
"qty": qty,
**extra,
}
def _counts(engine, table, where="1=1"):
with engine.connect() as conn:
return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one()
# ---------- 服务层 ----------
def test_unknown_trade_type_rejected(env):
"""§12 **R3 拆分后**:未知类型仍 400;convert 不再拒绝(改由下方 API 层覆盖)。
保留原 `test_convert_rejected` 的两条断言:不落 `core_trade`、
不落 `invalid_type` 审计(PRD 审计口径仅阻断/放行)。
"""
core, repo, writer, _, _ = env
with pytest.raises(UnsupportedTradeType, match="不支持的交易类型"):
submit_trade(_req(ttype="purchase"), core_ro=core, risk_repo=repo, gateway_repo=writer)
assert _counts(writer._engine, "core_trade") == 0
assert _counts(writer._engine, "audit_log", "decision='invalid_type'") == 0
def test_blocked_does_not_touch_core_trade(env):
"""A-1:C1 客户买 R4 → blocked=true(SUIT_RISK_MISMATCH)、不落 core_trade、日志/预警/审计齐全。"""
core, repo, writer, pub, engine = env
resp = submit_trade(_req(), core_ro=core, risk_repo=repo, gateway_repo=writer,
now=datetime(2026, 9, 6, 14, 0, 0))
assert resp["blocked"] is True
assert resp["block_response_code"] == "SUIT_RISK_MISMATCH" # main 契约机器码(SUIT-001 退役)
assert resp["advice"] == "请联系持证投资顾问" and resp["notice"] == "本次请求已记录"
assert _counts(engine, "core_trade") == 0 # 阻断不落交易
assert _counts(engine, "risk_suitability_log", "is_blocked=1 AND request_ref='" + resp["trade_id"] + "'") == 1
assert _counts(engine, "risk_alert", "alert_type='suitability'") == 1
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='suitability_blocked'") == 1
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='trade_accepted'") == 0
assert len(pub.messages) == 1 # suitability 预警推送
def test_accepted_trades_and_engine_fires(env):
"""A-3:C3 客户 60 万买 R3 → 落库 confirmed + 事件预警单(RISK-001/002) + 审计放行。"""
core, repo, writer, pub, engine = env
resp = submit_trade(
_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
)
assert resp["blocked"] is False
assert resp["trade_id"].startswith("TRD-20260906-")
assert resp["triggered_rules"] == ["RISK-001", "RISK-002"] # 含本笔累计
assert len(resp["alert_ids"]) == 1
with engine.connect() as conn:
row = conn.execute(
text("SELECT trade_status, amount FROM core_trade WHERE trade_id=:t"),
{"t": resp["trade_id"]},
).mappings().one()
assert row["trade_status"] == "confirmed" and Decimal(str(row["amount"])) == Decimal("600000")
alert = repo.get_alert(resp["alert_ids"][0])
assert alert["alert_type"] == "large_amount" and alert["risk_score"] == 70
assert alert["status"] == "pending_review" # 评审 P3-3 加固
assert _counts(engine, "risk_suitability_log", "is_blocked=0") == 1
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='trade_accepted'") == 1
assert _counts(engine, "customer_profile_l3", "monitor_tier='watch'") == 1
(channel, payload), = pub.messages
assert channel == "risk:pub:alert" and payload["risk_score"] == 70
def test_redeem_accepted_without_alert(env):
"""redeem 正向路径(评审 P3-3):小额赎回放行,无预警。"""
core, repo, writer, pub, engine = env
resp = submit_trade(
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", amount="1000"),
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
)
assert resp["blocked"] is False and resp["triggered_rules"] == []
assert _counts(engine, "core_trade", "trade_type='redeem'") == 1
assert _counts(engine, "risk_alert") == 0
def test_engine_failure_is_audited_and_degraded(env):
"""评审 P1-1:引擎异常 → 审计 risk_engine_error + 响应 engine_error=true(交易已成立)。"""
core, repo, writer, pub, engine = env
class Boom(Exception):
pass
monkey_patch = lambda *a, **k: (_ for _ in ()).throw(Boom())
saved = tg.process_trade_event
tg.process_trade_event = monkey_patch
try:
resp = submit_trade(
_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
core_ro=core, risk_repo=repo, gateway_repo=writer,
now=datetime(2026, 9, 6, 14, 0, 0),
)
finally:
tg.process_trade_event = saved
assert resp["blocked"] is False and resp["engine_error"] is True
assert _counts(engine, "core_trade") == 1 # 交易已成立
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='risk_engine_error'") == 1
assert _counts(engine, "risk_alert") == 0 # 引擎未跑,无预警
assert pub.messages == []
def test_missing_customer_returns_lookup_error(env):
"""AL-05 换核:NotFound 不再抛 LookupError,返回 forbidden/not_found 结构(main 契约)。"""
core, repo, writer, _, _ = env
resp = submit_trade(_req(customer="CUST-9999"), core_ro=core, risk_repo=repo, gateway_repo=writer)
assert resp["blocked"] is True
assert resp["block_response_code"] == "SUIT_NOT_FOUND"
# ---------- API 层(TestClient;仓储注入 sqlite) ----------
@pytest.fixture()
def client(env, monkeypatch):
core, repo, writer, pub, engine = env
monkeypatch.setattr(tg, "CoreReadOnlyRepository", lambda: core)
monkeypatch.setattr(tg, "RiskRepository", lambda: repo)
monkeypatch.setattr(tg, "GatewayRepository", lambda: writer)
# 401/越权审计经 deps/simulate 内仓储构造点,统一注入 sqlite(B6 评审 P3-4)
from app.api import deps as deps_mod
from app.api import simulate as simulate_mod
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
monkeypatch.setattr(simulate_mod, "_repo", lambda: repo)
app = FastAPI()
register_error_handlers(app) # 统一错误体(手册 §10,与 main 同一 handler 集)
app.include_router(simulate_router)
with TestClient(app) as c:
yield c
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-90001"}
# ---------- convert 请求模型与错误码映射(T-9 · 架构 §8.1/§8.3) ----------
def test_api_convert_missing_leg_fields_returns_422(client):
"""§12 **R4 改写**:convert 缺 from/to/qty → 422(模型分支校验)。
完整「走通 → 200」路径下放到 `test_convert_integration.py`(真 MySQL)——
单测层造齐产品/持仓/费率/净值成本高且与集成测试重复(§12 R4 降级方案)。
"""
r = client.post(
"/api/simulate/trade",
json={"customer_id": "CUST-3001", "trade_type": "convert", "qty": "1000"},
headers=DEMO,
)
assert r.status_code == 422
assert r.json()["error_code"] == "REQUEST_VALIDATION_FAILED"
def test_api_convert_bad_client_request_id_returns_422(client):
"""幂等键白名单与 X-Trace-Id **共用同一份正则**(S4)→ 非法字符 422。"""
r = client.post(
"/api/simulate/trade",
json=_convert_req(client_request_id="bad id!"),
headers=DEMO,
)
assert r.status_code == 422
def test_api_subscribe_missing_amount_returns_422(client):
"""反向验证字段池互斥:subscribe 仅给 product_id(缺 amount)→ 422。"""
r = client.post(
"/api/simulate/trade",
json={
"customer_id": "CUST-3001",
"trade_type": "subscribe",
"product_id": "PROD-510300",
},
headers=DEMO,
)
assert r.status_code == 422
#: 架构 §8.3 全量映射(含验收 14 的 `CROSS_ENTITY_NOT_SUPPORTED`)
CONVERT_ERROR_MATRIX = [
(ProductNotRedeemable(), 400, "PRODUCT_NOT_REDEEMABLE"),
(ProductNotSubscribable(), 400, "PRODUCT_NOT_SUBSCRIBABLE"),
(InsufficientShares(), 400, "INSUFFICIENT_SHARES"),
(BelowMinQty(), 400, "BELOW_MIN_QTY"),
(SameProduct(), 400, "SAME_PRODUCT"),
(CrossEntityNotSupported(), 400, "CROSS_ENTITY_NOT_SUPPORTED"),
(TooManyLots(3, 200), 400, "TOO_MANY_LOTS"),
(NavNotReady(), 503, "NAV_NOT_READY"),
(LotConflict(), 409, "LOT_CONFLICT"),
(IdempotencyUnavailable(), 503, "IDEMPOTENCY_UNAVAILABLE"),
(FeeRuleMissing(), 500, "FEE_RULE_MISSING"),
]
@pytest.mark.parametrize(
"exc,status,code",
CONVERT_ERROR_MATRIX,
ids=[e.error_code for e, _, _ in CONVERT_ERROR_MATRIX],
)
def test_convert_error_code_mapping(client, monkeypatch, exc, status, code):
"""每条 convert 异常 → HTTP 状态 + `error_code` 逐项对齐(架构 §8.3 表)。
做法:让 `convert_fund` 抛该异常,验统一错误体出口 —— convert 异常继承
`ApiError`,经 `register_error_handlers` 自动出体,**路由层不逐个 except**。
"""
def _boom(*_a, **_k):
raise exc
monkeypatch.setattr(tg, "convert_fund", _boom)
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
assert r.status_code == status
body = r.json()
assert body["error_code"] == code
assert body["trace_id"] and body["request_id"] # 统一错误体四要素仍在
def test_too_many_lots_body_carries_batch_count_and_max_lots(client, monkeypatch):
"""架构 §8.3:`TOO_MANY_LOTS` 错误体必须带 `batch_count`/`max_lots`。
本条同时锁住 `response.py` 的 `extra` 展开能力 —— 缺了它前端拿不到
「需跨 N 个批次、上限 200」的提示依据(执行期风险 #5 三重约束之一)。
"""
def _boom(*_a, **_k):
raise TooManyLots(250, 200)
monkeypatch.setattr(tg, "convert_fund", _boom)
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
assert r.status_code == 400
body = r.json()
assert body["error_code"] == "TOO_MANY_LOTS"
assert body["batch_count"] == 250
assert body["max_lots"] == 200
def test_api_convert_processing_returns_202(client, monkeypatch):
"""架构 §8.3:未抢到执行权 → **202** + `{convert_group_id, status}`。"""
monkeypatch.setattr(
tg,
"convert_fund",
lambda *_a, **_k: {"status": PROCESSING, "convert_group_id": None},
)
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
assert r.status_code == 202
assert r.json()["status"] == "processing"
def test_api_convert_accepted_returns_200(client, monkeypatch):
"""convert 走通 → 200 且**路由层原样透传**(完整折算数值见真库集成测试)。"""
fake = {"blocked": False, "convert_group_id": "CNV-TEST-1", "in_qty": "1000.00"}
monkeypatch.setattr(tg, "convert_fund", lambda *_a, **_k: fake)
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
assert r.status_code == 200
assert r.json() == fake
def test_convert_does_not_write_trade_request_audit(client, env, monkeypatch):
"""convert **不落 `trade_request` 审计** —— 审计由 convert_service 记 `convert_request`。
防的是「网关 + convert_service 双重审计」:一次转换被记成两条审计事件。
"""
_, _, _, _, engine = env
monkeypatch.setattr(
tg,
"convert_fund",
lambda *_a, **_k: {"blocked": False, "convert_group_id": "CNV-TEST-2"},
)
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
assert r.status_code == 200
assert _counts(engine, "audit_log", "event_type='trade_request'") == 0
def test_api_blocked_returns_200_with_blocked_true(client):
r = client.post("/api/simulate/trade", json=_req(), headers=DEMO) # C1 买 R4
assert r.status_code == 200
body = r.json()
assert body["blocked"] is True and body["block_response_code"] == "SUIT_RISK_MISMATCH"
def test_api_accepted_returns_200_with_trade_id(client):
r = client.post(
"/api/simulate/trade",
json=_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
headers=DEMO,
)
assert r.status_code == 200
body = r.json()
assert body["blocked"] is False and body["trade_id"].startswith("TRD-")
assert body["triggered_rules"] == ["RISK-001", "RISK-002"]
def test_api_customer_owner_allowed_other_denied(client, env):
"""B6 评审 P2-2:客户本人放行进业务(适当性阻断与否由业务层决定),查他人 403+审计。"""
_, repo, _, _, engine = env
# CUST-1001(C1)买 R3:鉴权通过进入业务,业务层适当性阻断(200 blocked=true)
r = client.post(
"/api/simulate/trade",
json=_req(customer="CUST-1001", product="PROD-510300", amount="1000"),
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"},
)
assert r.status_code == 200 and r.json()["blocked"] is True # 业务响应,非 403
r = client.post(
"/api/simulate/trade",
json=_req(customer="CUST-3001", product="PROD-510300", amount="1000"),
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"},
)
assert r.status_code == 403
# 网关越权审计与放行同口径 agent_type='platform'(复审 P3)
assert _counts(engine, "audit_log", "event_type='authz' AND decision='forbidden' AND agent_type='platform'") == 1
def test_api_non_positive_amount_returns_422(client):
r = client.post("/api/simulate/trade", json=_req(amount="0"), headers=DEMO)
assert r.status_code == 422
def test_api_unknown_customer_returns_404(client):
"""AL-05 换核:未知客户走 main 契约 → 200 + blocked + SUIT_NOT_FOUND(不再 404)。"""
r = client.post("/api/simulate/trade", json=_req(customer="CUST-9999"), headers=DEMO)
assert r.status_code == 200
body = r.json()
assert body["blocked"] is True
assert body["block_response_code"] == "SUIT_NOT_FOUND"