基金转换 T-12:补偿脚本(详情 + 预警)

阶段二失败(或阶段 1.5 引擎失败)后,仅凭 Core 侧数据把「详情 + 预警」两件事补回来
(PRD §7.1 / 架构 §5.4)。

落地(改 3 + 新增 2 脚本 + 测试 2 文件)
- convert_service:新增公开 compensate_convert(group_id, ...) —— 补偿的服务端单点入口
  · 锁键 convert:rerun:{gid},与 convert_fund 幂等重试路径同一个键
  · 详情侧:非 completed 才补写,复用 _finalize_from_core(不另写第二份阶段二)
  · 预警侧:幂等锚点 = 转出端 out_trade_id,复用 find_alerts_by_trade;
    命中即 skipped,否则跑 process_convert_event
- risk_repository:has_engine_error_audit 加 decision 参数(默认值不变)
  · convert 线阶段 1.5 失败审计用 engine_error,普通交易用 risk_engine_error,不是同一个码
- rebuild_alerts.py:新增 --convert-group(与 location 参数 trade_ids 互斥),薄封装
- 新增 scripts/agent/cleanup_pending_convert.py(架构 §2 与开发计划 §9 指定路径):
  超 convert_compensate_sla_hours 的 pending 占位 → status='expired'(标记不硬删,S2)
- 新增 scripts/dev/verify_convert_compensate.py:真库验证脚本(MySQL 8.0.46)
- 测试 +12:test_convert_service +4(补写 / 幂等 / missing / locked)、
  test_demo_scripts +8(--convert-group 分派与接线 + cleanup 脚本)

state 四态与 CLI 退出码
- rebuilt(0) / skipped(0 幂等) / missing(1 零写入) / locked(3)
- 退出码 2 保留给 argparse 用法错误,故 locked 取 3

真库专属证据(sqlite 单测给不了的,本任务核心增量)
- status='expired' 在 MySQL ENUM 上被接受(sqlite 该列是 VARCHAR,写什么都收)
- created_at < cutoff 在 DATETIME(3) 上的时间边界正确(超时进候选 / 未超时不进 / 复跑幂等)
- input_summary 是真 JSON 列,而 has_engine_error_audit 用 LIKE 判定:脚本先断言
  information_schema 的 DATA_TYPE='json' 再验命中,并反向断言决策码不匹配则不命中

顺带收口(用户指示)
- core_ro.concentration_profile 补 h.qty > 0,与 list_holdings 真正同口径
  · ratio 不变(归零行市值为 0),但 rows 不再多出已清仓产品、不虚占截断判定位
  · 新用例含跨出口一致性断言;突变验证:去掉 qty > 0 → 精准 1 条红

验证
- pytest -q → 731 passed / 3 skipped(基线 719 加 12,零回归)
- 突变验证 3 组精准命中:去掉幂等锚点(1 红)/ 去掉 status 过滤(2 红)/ 补偿无视锁(1 红)
- 真库 verify_convert_compensate.py 34/34,隔离数据零残留
- 全套 7 个真库脚本复跑零回归:seed 全 PASS / apply 24 / service 35 / engine 31 / lots 20 / tools 14 / compensate 34
This commit is contained in:
2026-09-10 18:59:42 +08:00
parent a6ac39edd6
commit 22f2a41192
13 changed files with 1296 additions and 32 deletions
+153 -1
View File
@@ -38,7 +38,11 @@ from app.service.convert.calc import (
lot_fee,
plan_lots,
)
from app.service.convert.convert_service import PROCESSING, convert_fund
from app.service.convert.convert_service import (
PROCESSING,
compensate_convert,
convert_fund,
)
from app.service.convert.errors import (
BelowMinQty,
CrossEntityNotSupported,
@@ -50,6 +54,7 @@ from app.service.convert.errors import (
)
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(放行)
@@ -468,3 +473,150 @@ def test_engine_wired_produces_single_alert_with_two_events(sqlite_engine):
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") == []