交付:新增 tests/test_convert_concurrency.py(9 条 · CONVERT_STRESS 门禁)+ test_convert_service 同键重试 3 条;app/repository/convert_repository.insert_placeholder 改三步法(R-a)、app/service/convert/convert_service.py 幂等判定 pending→202。
修复 2 处并发缺陷(压测暴露,sqlite 不可见):① 阶段一失败后同键重试永久 503(占位朴素 INSERT 撞 uk_group/uk_idem);② 同键竞态子窗口 A 真·双扣 / B 双扣+503(撞 uk_idem 直穿)。
验证:pytest 736 passed / 10 skipped(+5,跑两遍稳定);CONVERT_STRESS=1 并发 9/9;按 SOP 重灌双库复跑零回归;7 个 convert 真库脚本复跑零回归;突变 4 组全部精准命中。50 并发不超卖(成交 20~25)、紧池退避 40/50=80%(不够用)、松池 100%、跨客户 1213 死锁登记待评估;性能端到端 P50 44.2/max 59.8ms、阶段一 P50 9.8/max 16.7ms(未触阈值、未改实测值)。
文档:PRD v0.9.3(§9 第 18 条实测补录)+ 开发计划 §10(10.1~10.5)+ docs/memory/{TODO,MEMORY,FRAMEWORK,ITERATION,2026-09-10}。
730 lines
30 KiB
Python
730 lines
30 KiB
Python
"""T-7 `convert_service` 八步编排单测(开发计划 §6.2 DoD)。
|
||
|
||
sqlite 内存库(`conftest.sqlite_engine`,含 C×R 矩阵种子),数据自建。
|
||
折算期望值一律由生产 `calc.py` 实算(不自造公式副本,自检第 13 问)。
|
||
|
||
覆盖:
|
||
1. 三阶段贯通(占位 pending→completed / 2 流水 / 响应字段与 PRD §5.3 对齐)
|
||
2. **blocked 不占位**(PRD §7.0:前四步不落库)
|
||
3. 幂等命中返回首次结果、**不产生第二组流水**
|
||
4. 未抢到执行权 → `status=processing`(T-9 映射 202)
|
||
5. 全额转出豁免 `min_redeem_qty`(验收 16)/ 强制全转留痕
|
||
6. `nav_stale` 额外落副审计(1~2 条)
|
||
7. 阶段 1.5 引擎异常**不阻断**已成立交易
|
||
8. 阶段二失败 → `convert_detail_write_failed` 审计 + 占位 `failed`
|
||
9. 各 4xx/503 分支:SameProduct / 不可赎回 / 跨主体 / 份额不足 / 低于最低份额 /
|
||
无净值 / 批次数超限
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import date, datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from sqlalchemy import text
|
||
|
||
from app.gateway.convert_core_repository import ConvertCoreRepository
|
||
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.calc import (
|
||
convert_amount,
|
||
diff_fee,
|
||
hold_days,
|
||
in_qty,
|
||
lot_amount,
|
||
lot_fee,
|
||
plan_lots,
|
||
)
|
||
from app.service.convert.convert_service import (
|
||
PROCESSING,
|
||
compensate_convert,
|
||
convert_fund,
|
||
)
|
||
from app.service.convert.errors import (
|
||
BelowMinQty,
|
||
CrossEntityNotSupported,
|
||
InsufficientShares,
|
||
LotConflict,
|
||
NavNotReady,
|
||
ProductNotRedeemable,
|
||
SameProduct,
|
||
TooManyLots,
|
||
)
|
||
from app.service.convert.fee import pick_fee_rate
|
||
from app.service.convert.types import FeeRule, Lot
|
||
from app.service.risk.locks import try_lock
|
||
from app.service.risk.rules import RiskThresholds
|
||
|
||
CUST = "CUST-T7" # C3 客户 → R4 产品 allowed_with_disclosure(放行)
|
||
CUST_LOW = "CUST-T7L" # C1 客户 → R4 forbidden(用于 blocked 分支)
|
||
PROD_OUT = "PROD-T7A"
|
||
PROD_IN = "PROD-T7B"
|
||
COMPANY = "华夏模拟基金"
|
||
TA = "TA-CN-001"
|
||
NOW = datetime(2026, 9, 4, 10, 0, 0)
|
||
TODAY = date(2026, 9, 4)
|
||
OUT_RATE = Decimal("0.0030")
|
||
IN_RATE = Decimal("0.0080")
|
||
IN_NAV = Decimal("0.9500")
|
||
FEE_TIERS = [
|
||
(0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"),
|
||
(180, 365, "0.0025"), (365, None, "0.0000"),
|
||
]
|
||
|
||
|
||
def _exec(engine, sql: str, **params) -> None:
|
||
with engine.begin() as conn:
|
||
conn.execute(text(sql), params)
|
||
|
||
|
||
def _rows(engine, sql: str, **params) -> list[dict]:
|
||
with engine.connect() as conn:
|
||
return [dict(r) for r in conn.execute(text(sql), params).mappings()]
|
||
|
||
|
||
def _seed(engine, *, nav_date: date = TODAY, min_redeem: str = "0", min_hold: str = "0") -> None:
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
|
||
"VALUES (:c, 'T7客户', 40, 1)",
|
||
c=CUST,
|
||
)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
|
||
"VALUES (:c, 'T7低风险客户', 40, 1)",
|
||
c=CUST_LOW,
|
||
)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
|
||
"VALUES (:c, 'C3', :t, :exp)",
|
||
c=CUST, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
|
||
)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) "
|
||
"VALUES (:c, 'C1', :t, :exp)",
|
||
c=CUST_LOW, t=NOW - timedelta(days=30), exp=NOW + timedelta(days=300),
|
||
)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type, "
|
||
"can_subscribe, can_redeem, subscribe_fee_rate, min_redeem_qty, min_hold_qty, "
|
||
"fund_company, ta_code) VALUES (:p, '转出债基', 'R2', 'bond', 1, 1, :r, :mr, :mh, :co, :ta)",
|
||
p=PROD_OUT, r=float(OUT_RATE), mr=float(min_redeem), mh=float(min_hold),
|
||
co=COMPANY, ta=TA,
|
||
)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type, "
|
||
"can_subscribe, can_redeem, subscribe_fee_rate, fund_company, ta_code) "
|
||
"VALUES (:p, '转入股基', 'R4', 'stock', 1, 1, :r, :co, :ta)",
|
||
p=PROD_IN, r=float(IN_RATE), co=COMPANY, ta=TA,
|
||
)
|
||
for mh, mh_max, rate in FEE_TIERS:
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days, max_hold_days, rate) "
|
||
"VALUES (:p, 'redeem', :mh, :mh_max, :rate)",
|
||
p=PROD_OUT, mh=mh, mh_max=mh_max, rate=float(rate),
|
||
)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) "
|
||
"VALUES (:p, :nav, 0, :d)",
|
||
p=PROD_IN, nav=float(IN_NAV), d=nav_date,
|
||
)
|
||
_seed_lot(engine, "LOT-T7-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0))
|
||
_seed_lot(engine, "LOT-T7-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0))
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value, "
|
||
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
|
||
c=CUST, p=PROD_OUT, d=TODAY,
|
||
)
|
||
# CUST_LOW(C1,R4 应被适当性拦截)也需有份额,否则 ② 份额校验会先于 ④ 触发
|
||
_seed_lot(engine, "LOT-T7L-1", "100", "1.0300", datetime(2026, 8, 1, 10, 0, 0),
|
||
customer=CUST_LOW)
|
||
_seed_lot(engine, "LOT-T7L-2", "50", "1.0000", datetime(2026, 9, 1, 10, 0, 0),
|
||
customer=CUST_LOW)
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, market_value, "
|
||
"pnl_pct, as_of) VALUES (:c, :p, 150, 150, 154.5, 0, :d)",
|
||
c=CUST_LOW, p=PROD_OUT, d=TODAY,
|
||
)
|
||
|
||
|
||
def _seed_lot(engine, lot_id: str, qty: str, nav: str, confirmed_at: datetime,
|
||
customer: str = CUST) -> None:
|
||
_exec(
|
||
engine,
|
||
"INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty, remain_qty, nav, "
|
||
"confirmed_at) VALUES (:l, :c, :p, :q, :q, :nav, :cat)",
|
||
l=lot_id, c=customer, p=PROD_OUT, q=float(qty), nav=float(nav), cat=confirmed_at,
|
||
)
|
||
|
||
|
||
def _services(engine):
|
||
return dict(
|
||
core_ro=CoreReadOnlyRepository(engine=engine),
|
||
risk_repo=RiskRepository(engine=engine),
|
||
convert_repo=ConvertRepository(engine=engine),
|
||
core_writer=ConvertCoreRepository(engine=engine),
|
||
)
|
||
|
||
|
||
def _req(customer: str = CUST, qty: str = "120", cid_req: str | None = None) -> dict:
|
||
return {
|
||
"customer_id": customer,
|
||
"from_product_id": PROD_OUT,
|
||
"to_product_id": PROD_IN,
|
||
"qty": qty,
|
||
"client_request_id": cid_req,
|
||
}
|
||
|
||
|
||
def _expected(engine, requested: str) -> dict:
|
||
"""用生产纯函数算一遍期望值(与 service 内部同一套口径)。"""
|
||
core = CoreReadOnlyRepository(engine=engine)
|
||
lots = [Lot.from_row(r) for r in core.list_share_lots(CUST, PROD_OUT)]
|
||
rules = [FeeRule.from_row(r) for r in core.get_redeem_fee_rules(PROD_OUT)]
|
||
plan = plan_lots(lots, Decimal(requested))
|
||
out_amount = Decimal("0")
|
||
redeem_fee = Decimal("0")
|
||
for alloc in plan.allocations:
|
||
amount = lot_amount(alloc.qty, alloc.nav)
|
||
rate = pick_fee_rate(rules, hold_days(TODAY, alloc.confirmed_at), product_id=PROD_OUT)
|
||
out_amount += amount
|
||
redeem_fee += lot_fee(amount, rate)
|
||
conv = convert_amount(out_amount, redeem_fee)
|
||
gap = diff_fee(conv, OUT_RATE, IN_RATE, "amount_diff")
|
||
in_amount = conv - gap
|
||
return {
|
||
"out_amount": out_amount,
|
||
"redeem_fee": redeem_fee,
|
||
"convert_amount": conv,
|
||
"diff_fee": gap,
|
||
"in_amount": in_amount,
|
||
"in_qty": in_qty(in_amount, IN_NAV),
|
||
"actual_qty": plan.actual_qty,
|
||
}
|
||
|
||
|
||
# ── 1. 三阶段贯通 ───────────────────────────────────────────────────
|
||
def test_happy_path_writes_two_trades_and_completes_placeholder(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
exp = _expected(sqlite_engine, "120")
|
||
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
|
||
|
||
assert resp["blocked"] is False
|
||
assert resp["estimated"] is True
|
||
assert resp["convert_group_id"].startswith("CNV-")
|
||
assert Decimal(resp["out_amount"]) == exp["out_amount"]
|
||
assert Decimal(resp["redeem_fee"]) == exp["redeem_fee"]
|
||
assert Decimal(resp["convert_amount"]) == exp["convert_amount"]
|
||
assert Decimal(resp["diff_fee"]) == exp["diff_fee"]
|
||
assert Decimal(resp["in_amount"]) == exp["in_amount"]
|
||
assert Decimal(resp["in_qty"]) == exp["in_qty"]
|
||
assert Decimal(resp["actual_qty"]) == exp["actual_qty"]
|
||
assert resp["lot_count"] == 2
|
||
assert [b["hold_days"] for b in resp["lot_breakdown"]] == [34, 3]
|
||
assert resp["confirm_basis"] == "natural_day_approx"
|
||
|
||
# 阶段二:占位 completed
|
||
rows = _rows(
|
||
sqlite_engine,
|
||
"SELECT * FROM risk_convert_detail WHERE convert_group_id = :g",
|
||
g=resp["convert_group_id"],
|
||
)
|
||
assert len(rows) == 1 and rows[0]["status"] == "completed"
|
||
# 两条流水同组、redeem/subscribe(R-b)
|
||
trades = _rows(
|
||
sqlite_engine,
|
||
"SELECT * FROM core_trade WHERE convert_group_id = :g ORDER BY trade_type",
|
||
g=resp["convert_group_id"],
|
||
)
|
||
assert [t["trade_type"] for t in trades] == ["redeem", "subscribe"]
|
||
# 主审计 1 条(净值新鲜 → 无 nav_stale 副审计)
|
||
assert len(
|
||
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
|
||
) == 1
|
||
assert _rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'") == []
|
||
|
||
|
||
# ── 2. blocked 不占位(PRD §7.0)─────────────────────────────────────
|
||
def test_suitability_blocked_does_not_placeholder(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
resp = convert_fund(
|
||
_req(customer=CUST_LOW), now=NOW, **_services(sqlite_engine)
|
||
)
|
||
assert resp["blocked"] is True
|
||
assert resp["block_response_code"]
|
||
# 关键:前四步不落库 —— 无占位、无流水
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
|
||
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
|
||
assert len(
|
||
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'suitability_blocked'")
|
||
) == 1
|
||
|
||
|
||
# ── 3. 幂等:同键重复提交不产生第二组流水 ────────────────────────────
|
||
def test_idempotent_repeat_returns_first_result(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
svc = _services(sqlite_engine)
|
||
first = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
|
||
again = convert_fund(_req(cid_req="CLI-T7-001"), now=NOW, **svc)
|
||
|
||
assert again["convert_group_id"] == first["convert_group_id"]
|
||
assert again["in_qty"] == first["in_qty"]
|
||
assert again["out_trade_id"] == first["out_trade_id"]
|
||
# 只有一组流水(2 条),没有第二组
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1
|
||
|
||
|
||
# ── 4. 未抢到执行权 → processing(T-9 映射 202)───────────────────────
|
||
def test_lock_not_acquired_returns_processing(sqlite_engine, monkeypatch):
|
||
import app.service.convert.convert_service as cs
|
||
from app.service.risk.locks import _NoLock
|
||
|
||
_seed(sqlite_engine)
|
||
monkeypatch.setattr(cs, "try_lock", lambda *a, **k: _NoLock())
|
||
resp = convert_fund(_req(cid_req="CLI-T7-002"), now=NOW, **_services(sqlite_engine))
|
||
|
||
assert resp["status"] == PROCESSING
|
||
assert "convert_group_id" in resp
|
||
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
|
||
|
||
|
||
# ── 5. 全额转出豁免最低份额(验收 16)────────────────────────────────
|
||
def test_full_transfer_waives_min_redeem_qty(sqlite_engine):
|
||
_seed(sqlite_engine, min_redeem="10000")
|
||
# 持 150(< min_redeem 10000)但申请全额 → 豁免,成功
|
||
resp = convert_fund(_req(qty="150"), now=NOW, **_services(sqlite_engine))
|
||
assert resp["blocked"] is False
|
||
assert Decimal(resp["actual_qty"]) == Decimal("150")
|
||
|
||
|
||
def test_below_min_qty_rejected_when_not_full(sqlite_engine):
|
||
_seed(sqlite_engine, min_redeem="10000")
|
||
# 持 150、申请 100(< 最低 10000,且非全额)→ 400 BELOW_MIN_QTY
|
||
with pytest.raises(BelowMinQty):
|
||
convert_fund(_req(qty="100"), now=NOW, **_services(sqlite_engine))
|
||
|
||
|
||
# ── 6. 强制全转留痕 ─────────────────────────────────────────────────
|
||
def test_forced_full_transfer_flag(sqlite_engine):
|
||
_seed(sqlite_engine, min_hold="100")
|
||
# 持 150、申请 140 → 余额 10 < 100 → 强制全转 150
|
||
resp = convert_fund(_req(qty="140"), now=NOW, **_services(sqlite_engine))
|
||
assert resp["forced_full_transfer"] is True
|
||
assert resp["min_hold_action"] == "force_transfer"
|
||
assert Decimal(resp["actual_qty"]) == Decimal("150")
|
||
|
||
|
||
# ── 7. nav_stale 额外落副审计 ────────────────────────────────────────
|
||
def test_nav_stale_adds_second_audit(sqlite_engine):
|
||
_seed(sqlite_engine, nav_date=date(2026, 8, 25)) # 距今 10 天 > 3
|
||
resp = convert_fund(_req(), now=NOW, **_services(sqlite_engine))
|
||
assert resp["nav_stale"] is True
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'nav_stale'")) == 1
|
||
assert len(
|
||
_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'convert_accepted'")
|
||
) == 1
|
||
|
||
|
||
# ── 8. 阶段 1.5:引擎异常不阻断已成立的交易 ───────────────────────────
|
||
def test_engine_exception_does_not_block_trade(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
|
||
def boom(out_trade, in_trade): # noqa: ANN001
|
||
raise RuntimeError("引擎炸了")
|
||
|
||
resp = convert_fund(_req(), now=NOW, engine_hook=boom, **_services(sqlite_engine))
|
||
assert resp["blocked"] is False
|
||
assert resp["engine_error"] is True
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2 # 交易仍成立
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM audit_log WHERE decision = 'engine_error'")) == 1
|
||
|
||
|
||
def test_engine_result_merged_into_response(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
hook = lambda o, i: { # noqa: E731
|
||
"triggered_rules": ["RISK-002"],
|
||
"alert_ids": ["ALT-1"],
|
||
"aml_hit": False,
|
||
}
|
||
resp = convert_fund(_req(), now=NOW, engine_hook=hook, **_services(sqlite_engine))
|
||
assert resp["triggered_rules"] == ["RISK-002"]
|
||
assert resp["alert_ids"] == ["ALT-1"]
|
||
|
||
|
||
# ── 9. 阶段二失败:留痕 + 占位 failed(不回滚 Core)───────────────────
|
||
def test_phase_two_failure_keeps_trade_and_marks_failed(sqlite_engine, monkeypatch):
|
||
_seed(sqlite_engine)
|
||
|
||
def boom(self, group_id, **kwargs): # noqa: ANN001
|
||
raise RuntimeError("阶段二写失败")
|
||
|
||
monkeypatch.setattr(ConvertRepository, "complete_convert", boom)
|
||
resp = convert_fund(
|
||
_req(cid_req="CLI-T7-003"), now=NOW, **_services(sqlite_engine)
|
||
)
|
||
# 交易已成立:core 有两条流水,响应照常返回
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
|
||
assert resp["blocked"] is False
|
||
assert len(
|
||
_rows(
|
||
sqlite_engine,
|
||
"SELECT * FROM audit_log WHERE decision = 'convert_detail_write_failed'",
|
||
)
|
||
) == 1
|
||
rows = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
|
||
assert rows[0]["status"] == "failed"
|
||
|
||
|
||
# ── 9b. 阶段一失败 → 同键重试必须可成功(T-13 前置修复的回归闸门)──────
|
||
def test_retry_after_phase_one_failure_succeeds_with_same_key(
|
||
sqlite_engine, monkeypatch,
|
||
):
|
||
"""`LotConflict`(409) 后带**同一** `client_request_id` 重试 → 成功、只有一组流水。
|
||
|
||
这是架构 §8.3「`LOT_CONFLICT` → 调用方重试,建议 ≤3 次、间隔 100/200/400ms」的
|
||
服务端契约。修复前:占位被 `mark_failed` 置 failed,重试复用同一 `group_id`
|
||
再次进入阶段零,`insert_placeholder` 的朴素 INSERT 撞 `uk_group`/`uk_idem`
|
||
→ `IdempotencyUnavailable`(503) —— **确定性失败**,重试永远不会成功。
|
||
"""
|
||
_seed(sqlite_engine)
|
||
original = ConvertCoreRepository.apply_convert
|
||
|
||
def conflict(self, req): # noqa: ANN001
|
||
raise LotConflict("并发争抢:条件 UPDATE rowcount=0")
|
||
|
||
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", conflict)
|
||
with pytest.raises(LotConflict):
|
||
convert_fund(_req(cid_req="CLI-T13-RETRY"), now=NOW, **_services(sqlite_engine))
|
||
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", original)
|
||
|
||
# 前置态:占位 failed、两条流水都没落
|
||
pre = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
|
||
assert len(pre) == 1 and pre[0]["status"] == "failed"
|
||
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
|
||
first_gid = pre[0]["convert_group_id"]
|
||
|
||
# 带同键重试 → 必须成功,且复用原 group_id(杜绝第二组流水)
|
||
resp = convert_fund(_req(cid_req="CLI-T13-RETRY"), now=NOW, **_services(sqlite_engine))
|
||
assert resp["convert_group_id"] == first_gid
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")[0]["status"] == "completed"
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM risk_convert_detail")) == 1 # 不新增占位行
|
||
|
||
|
||
def test_retry_after_phase_one_failure_can_fail_again_and_still_retry(
|
||
sqlite_engine, monkeypatch,
|
||
):
|
||
"""连续两次 409 后再重试仍能成功 —— 证明「failed → pending」可反复回置。"""
|
||
_seed(sqlite_engine)
|
||
original = ConvertCoreRepository.apply_convert
|
||
state = {"n": 0}
|
||
|
||
def conflict_twice(self, req): # noqa: ANN001
|
||
state["n"] += 1
|
||
if state["n"] <= 2:
|
||
raise LotConflict("第 %d 次争抢失败" % state["n"])
|
||
original(self, req)
|
||
|
||
monkeypatch.setattr(ConvertCoreRepository, "apply_convert", conflict_twice)
|
||
for _ in range(2):
|
||
with pytest.raises(LotConflict):
|
||
convert_fund(_req(cid_req="CLI-T13-RETRY2"), now=NOW, **_services(sqlite_engine))
|
||
|
||
resp = convert_fund(_req(cid_req="CLI-T13-RETRY2"), now=NOW, **_services(sqlite_engine))
|
||
assert resp["blocked"] is False and resp["in_qty"]
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
|
||
assert [r["status"] for r in _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")] == [
|
||
"completed"
|
||
]
|
||
|
||
|
||
def test_placeholder_uk_idem_race_returns_processing_without_writing(
|
||
sqlite_engine, monkeypatch,
|
||
):
|
||
"""同键并发子窗口(两笔都判定"无占位")→ 撞 `uk_idem` 的那笔必须回 202,且零写入。
|
||
|
||
构造方式:**只把"按 cid 查"这一读打桩成 None**(等价于并发下"那一行还没插进来"),
|
||
占位表里预置另一笔同 cid 的占位。于是:
|
||
① 幂等判定读不到 → 本笔自生成新 group_id;
|
||
② 阶段零 INSERT 撞 `uk_idem` → `insert_placeholder` 返回 False → 本笔让路。
|
||
|
||
这是**资金安全**的闸门:若不接住这个信号(仓储吞掉 False、调用方照跑),
|
||
两笔会各带不同 group_id 跑完阶段一 —— `in_lot_id` 派生自 group_id 不再相撞
|
||
→ **双扣**(真库突变验证实测:120 份变 240 份、两组流水,见
|
||
`tests/test_convert_concurrency.py::test_same_client_request_id_placeholder_race_must_not_leak_5xx`)。
|
||
"""
|
||
_seed(sqlite_engine)
|
||
_exec(
|
||
sqlite_engine,
|
||
"INSERT INTO risk_convert_detail"
|
||
" (convert_group_id, client_request_id, status, estimated)"
|
||
" VALUES ('CNV-OTHER-CONC', 'CID-RACE', 'pending', 0)",
|
||
)
|
||
monkeypatch.setattr(
|
||
ConvertRepository, "get_by_client_request_id", lambda self, cid: None
|
||
)
|
||
|
||
resp = convert_fund(_req(cid_req="CID-RACE"), now=NOW, **_services(sqlite_engine))
|
||
|
||
assert resp["status"] == PROCESSING # 让路 → 202(T-9 映射)
|
||
assert resp["convert_group_id"] is None
|
||
# 零写入:没有第二组流水、没有第二行占位、没有扣份额
|
||
assert _rows(sqlite_engine, "SELECT * FROM core_trade") == []
|
||
placeholders = _rows(sqlite_engine, "SELECT * FROM risk_convert_detail")
|
||
assert len(placeholders) == 1 and placeholders[0]["convert_group_id"] == "CNV-OTHER-CONC"
|
||
assert Decimal(
|
||
_rows(
|
||
sqlite_engine,
|
||
"SELECT SUM(remain_qty) AS s FROM core_share_lot WHERE customer_id = :c",
|
||
c=CUST,
|
||
)[0]["s"]
|
||
) == Decimal("150")
|
||
|
||
|
||
# ── 10. 各 4xx / 503 分支 ───────────────────────────────────────────
|
||
def test_same_product_rejected(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
req = _req()
|
||
req["to_product_id"] = PROD_OUT
|
||
with pytest.raises(SameProduct):
|
||
convert_fund(req, now=NOW, **_services(sqlite_engine))
|
||
|
||
|
||
def test_cross_entity_rejected(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
_exec(
|
||
sqlite_engine,
|
||
"UPDATE core_product SET fund_company = '易方达模拟基金' WHERE product_id = :p",
|
||
p=PROD_IN,
|
||
)
|
||
with pytest.raises(CrossEntityNotSupported):
|
||
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
|
||
|
||
|
||
def test_out_product_not_redeemable_rejected(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
_exec(
|
||
sqlite_engine,
|
||
"UPDATE core_product SET can_redeem = 0 WHERE product_id = :p",
|
||
p=PROD_OUT,
|
||
)
|
||
with pytest.raises(ProductNotRedeemable):
|
||
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
|
||
|
||
|
||
def test_insufficient_shares_rejected(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
with pytest.raises(InsufficientShares):
|
||
convert_fund(_req(qty="500"), now=NOW, **_services(sqlite_engine))
|
||
|
||
|
||
def test_nav_not_ready_503(sqlite_engine):
|
||
_seed(sqlite_engine)
|
||
_exec(sqlite_engine, "DELETE FROM core_product_nav")
|
||
with pytest.raises(NavNotReady) as exc:
|
||
convert_fund(_req(), now=NOW, **_services(sqlite_engine))
|
||
assert exc.value.status_code == 503
|
||
|
||
|
||
def test_too_many_lots_rejected(sqlite_engine, monkeypatch):
|
||
from app.config import settings as settings_module
|
||
|
||
_seed(sqlite_engine)
|
||
monkeypatch.setattr(settings_module.settings, "convert_batch_max_lots", 1)
|
||
with pytest.raises(TooManyLots) as exc:
|
||
convert_fund(_req(qty="120"), now=NOW, **_services(sqlite_engine))
|
||
assert exc.value.extra == {"batch_count": 2, "max_lots": 1}
|
||
|
||
|
||
# ── 10. 阶段 1.5 接线(T-8):引擎真跑并出单 ─────────────────────────
|
||
def test_engine_wired_produces_single_alert_with_two_events(sqlite_engine):
|
||
"""T-8 落地后阶段 1.5 不再跳过:大额转换**真出一张单**、`payload.events` 两条(验收 7)。
|
||
|
||
本用例是「接线回归」:若 `_run_engine` 又被改回静默跳过(或签名对不上被
|
||
ImportError 吞掉),这里会因 `triggered_rules` 为空而变红。
|
||
"""
|
||
_seed(sqlite_engine)
|
||
th = RiskThresholds(
|
||
large_amount=Decimal("100"), # 调低以让 120 份的折算额命中 RISK-001
|
||
daily_total=Decimal("1000000"),
|
||
freq_count=3,
|
||
probe_window_minutes=5,
|
||
probe_count=3,
|
||
probe_amount=Decimal("400000"),
|
||
small_amount=Decimal("10000"),
|
||
small_count=3,
|
||
concentration_threshold=1.01,
|
||
)
|
||
resp = convert_fund(_req(), now=NOW, thresholds=th, **_services(sqlite_engine))
|
||
|
||
assert resp["engine_error"] is False, "引擎真的跑了且没炸(未走 ImportError 跳过分支)"
|
||
assert "RISK-001" in resp["triggered_rules"]
|
||
assert len(resp["alert_ids"]) == 1, "一次转换只出一张单"
|
||
|
||
alerts = _rows(sqlite_engine, "SELECT * FROM risk_alert")
|
||
assert len(alerts) == 1
|
||
payload = json.loads(alerts[0]["payload"])
|
||
assert len(payload["events"]) == 2, "一张单承载两条事件(转出 + 转入)"
|
||
assert [e["trade_type"] for e in payload["events"]] == ["redeem", "subscribe"]
|
||
|
||
|
||
# ── 11. T-12 补偿:阶段二失败 → 按 group 补写详情 + 预警(FR-C17 / 架构 §5.4)──
|
||
def _low_thresholds() -> RiskThresholds:
|
||
"""调低大额阈值,让 120 份的折算额足以命中 RISK-001(与 §10 接线用例同口径)。"""
|
||
return RiskThresholds(
|
||
large_amount=Decimal("100"),
|
||
daily_total=Decimal("1000000"),
|
||
freq_count=3,
|
||
probe_window_minutes=5,
|
||
probe_count=3,
|
||
probe_amount=Decimal("400000"),
|
||
small_amount=Decimal("10000"),
|
||
small_count=3,
|
||
concentration_threshold=1.01,
|
||
)
|
||
|
||
|
||
def _prepare_failed_convert(sqlite_engine, monkeypatch, cid_req: str) -> str:
|
||
"""构造**真实待补偿态**:Core 侧两条流水已成立,但详情与预警双双缺失。
|
||
|
||
- 阶段二:`complete_convert` 打桩抛异常 → 占位 `failed` + `convert_detail_write_failed` 审计;
|
||
- 阶段 1.5:注入会抛的 `engine_hook` → 无预警单 + `engine_error` 审计。
|
||
|
||
这正是 PRD §7.1 描述的补偿场景(agent 侧两件事都没落)。
|
||
返回 convert_group_id;`complete_convert` 已被还原(补偿才能真的写进去)。
|
||
"""
|
||
_seed(sqlite_engine)
|
||
original = ConvertRepository.complete_convert
|
||
|
||
def boom(self, group_id, **kwargs): # noqa: ANN001
|
||
raise RuntimeError("阶段二写失败")
|
||
|
||
def engine_boom(out_trade, in_trade): # noqa: ANN001
|
||
raise RuntimeError("阶段 1.5 引擎失败")
|
||
|
||
monkeypatch.setattr(ConvertRepository, "complete_convert", boom)
|
||
resp = convert_fund(
|
||
_req(cid_req=cid_req),
|
||
now=NOW,
|
||
engine_hook=engine_boom,
|
||
thresholds=_low_thresholds(),
|
||
**_services(sqlite_engine),
|
||
)
|
||
monkeypatch.setattr(ConvertRepository, "complete_convert", original)
|
||
return resp["convert_group_id"]
|
||
|
||
|
||
def _compensate(sqlite_engine, gid: str) -> dict:
|
||
svc = _services(sqlite_engine)
|
||
return compensate_convert(
|
||
gid,
|
||
core_ro=svc["core_ro"],
|
||
risk_repo=svc["risk_repo"],
|
||
convert_repo=svc["convert_repo"],
|
||
thresholds=_low_thresholds(),
|
||
now=NOW,
|
||
)
|
||
|
||
|
||
def _detail_rows(engine, gid: str) -> list[dict]:
|
||
return _rows(
|
||
engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid
|
||
)
|
||
|
||
|
||
def test_compensate_rebuilds_detail_and_alerts(sqlite_engine, monkeypatch):
|
||
"""补偿把「详情 + 预警」两件事都补齐,且详情直接置 completed(验收 17)。"""
|
||
gid = _prepare_failed_convert(sqlite_engine, monkeypatch, "CLI-T12-001")
|
||
# 前置态自检:交易已成立、详情 failed、无预警单
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM core_trade")) == 2
|
||
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed"
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
|
||
assert len(
|
||
_rows(
|
||
sqlite_engine,
|
||
"SELECT * FROM audit_log WHERE decision = 'convert_detail_write_failed'",
|
||
)
|
||
) == 1
|
||
|
||
out = _compensate(sqlite_engine, gid)
|
||
|
||
assert out["state"] == "rebuilt"
|
||
assert out["detail"] == "rebuilt" and out["engine"] == "rebuilt"
|
||
assert "RISK-001" in out["triggered_rules"]
|
||
assert len(out["alert_ids"]) == 1
|
||
row = _detail_rows(sqlite_engine, gid)[0]
|
||
assert row["status"] == "completed"
|
||
# 补偿回填的指针与 Core 侧一致(幂等锚点 = 转出端)
|
||
assert row["out_trade_id"] == out["out_trade_id"]
|
||
assert row["in_trade_id"] == out["in_trade_id"]
|
||
assert row["nav"] is not None and row["nav_date"] is not None
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM risk_alert")) == 1
|
||
# 补偿也补了主审计(与首次成功路径同一份 `_write_main_audit`)
|
||
assert len(
|
||
_rows(
|
||
sqlite_engine,
|
||
"SELECT * FROM audit_log WHERE decision = 'convert_accepted'",
|
||
)
|
||
) == 1
|
||
|
||
|
||
def test_compensate_is_idempotent(sqlite_engine, monkeypatch):
|
||
"""重复补偿 → `skipped`,不产生第二张预警单、不重复写详情与主审计。"""
|
||
gid = _prepare_failed_convert(sqlite_engine, monkeypatch, "CLI-T12-002")
|
||
first = _compensate(sqlite_engine, gid)
|
||
second = _compensate(sqlite_engine, gid)
|
||
|
||
assert first["state"] == "rebuilt"
|
||
assert second["state"] == "skipped"
|
||
assert second["detail"] == "already_completed"
|
||
assert second["engine"] == "skipped"
|
||
assert second["alert_ids"] == first["alert_ids"]
|
||
assert second["triggered_rules"] == []
|
||
assert len(_rows(sqlite_engine, "SELECT * FROM risk_alert")) == 1 # 只有一张单
|
||
assert len(
|
||
_rows(
|
||
sqlite_engine,
|
||
"SELECT * FROM audit_log WHERE decision = 'convert_accepted'",
|
||
)
|
||
) == 1 # 主审计不重复
|
||
# 曾阶段 1.5 中断过 → 幂等跳过时附「人工核对」提示(与 rebuild_alerts 同款警示)
|
||
assert "人工核对" in second["warning"]
|
||
|
||
|
||
def test_compensate_missing_group_touches_nothing(sqlite_engine):
|
||
"""Core 侧不足两条流水 → missing,零写入(不是有效转换组)。"""
|
||
_seed(sqlite_engine)
|
||
out = _compensate(sqlite_engine, "CNV-T12-NOPE")
|
||
assert out["state"] == "missing"
|
||
assert out["trade_count"] == 0
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
|
||
assert _rows(sqlite_engine, "SELECT * FROM audit_log") == []
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_convert_detail") == []
|
||
|
||
|
||
def test_compensate_returns_locked_when_execution_right_taken(
|
||
sqlite_engine, monkeypatch
|
||
):
|
||
"""未抢到 `convert:rerun:{gid}` → locked(有并发重试/实例在跑),零写入。"""
|
||
gid = _prepare_failed_convert(sqlite_engine, monkeypatch, "CLI-T12-004")
|
||
with try_lock(f"convert:rerun:{gid}", 30) as acquired:
|
||
assert acquired is True
|
||
out = _compensate(sqlite_engine, gid)
|
||
assert out["state"] == "locked" and out["alert_ids"] == []
|
||
assert _detail_rows(sqlite_engine, gid)[0]["status"] == "failed" # 仍待补偿
|
||
assert _rows(sqlite_engine, "SELECT * FROM risk_alert") == []
|