基金转换 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:
@@ -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") == []
|
||||
|
||||
Reference in New Issue
Block a user