Files
group_xinghuo_jinrong/tests/test_demo_scripts.py
T
GaoYiYuan_0626 03feacf173 基金转换 T+1 模型:T-12 补偿 + SLA 清理切 Core 链路 + confirm_one 引擎异常留痕修复
- cleanup_pending_convert.py 整体重写:扫描对象迁 Core(list_inflight_before 扫
  accepted/nav_pending 严格早于 cutoff → 条件 UPDATE 置 expired,S2 不硬删、
  幂等不加锁);SLA 边界 = 当前业务日上推 convert_confirm_sla_days 个交易日
  00:00(previous_biz_day 链式,与 confirm_batch._window_start 同口径);
  --days/--dry-run;不动 agent 镜像(T-4 契约)
- compensate_convert 从 v1.0 Deprecated 组救回在役:详情侧 Core 三件套前置校验
  (流水≥2 → confirmed → 明细非空,任一不满足 missing 零写入),补写与确认段
  第⑧步同口径 sync_mirror + confirmed 审计 phase=confirm-compensate
- convert_request_repository +list_inflight_before(T-12 专用捞单,R-2 守卫复用)
- 修复 confirm_one 第⑦步引擎异常只 log 不留痕(全仓唯一不落痕路径,补偿侧
  「人工核对」保护对主链路失效):except 补写 decision='engine_error' 审计 +
  engine_error 标志贯穿审计与响应(D17 不阻断保留;三链路与 v1.0/gateway 对齐)
- verify_convert_compensate.py 整体重写 T+1 链路:受理→确认双失败→Core 三件套
  对表→补偿/cleanup,真库 56/56 退出码 0 残留 0(受理日/SLA 边界走真库交易日历
  链式推算,A 股最长连续休市 2020 春节 10 天已联网核实留痕)
- 测试:补偿用例 4 改 + 2 增、cleanup 段重写 + 窗口等价新用例、并发补偿 T+1 造数
  + 真库清场补 core_convert_request(FK 1451)、engine 异常用例断言翻转
  (不阻断但留痕+标记);全量 849 passed / 10 skipped(846+3 零回归);
  突变 4 组全被抓还原(cleanup 窗口反转 5 红 / 前置守卫 1 红 / 真库
  expired→cancelled 2 红 / 真库 nav_stale 1 红)
2026-09-12 10:54:49 +08:00

469 lines
19 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.
"""B9a 演示/运维脚本单测(开发计划 B9a · sqlite)。
rebuild_alerts:补偿重放出单并推送、幂等跳过(防重复 append/aml 重复出单)、
missing 不落库;subscribe_alerts:payload → 单行可读文本。
**T-12 追加**:`rebuild_alerts --convert-group` 的分派与接线、
`cleanup_pending_convert` 超时占位清理(标记不硬删)。
脚本目录非包,动态入 sys.path 后按模块名导入。
"""
import sys
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
DEMO_DIR = Path(__file__).resolve().parents[1] / "scripts" / "demo"
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(DEMO_DIR))
sys.path.insert(0, str(ROOT / "scripts" / "agent"))
import rebuild_alerts # noqa: E402
from rebuild_alerts import main, rebuild_convert_group, rebuild_trade # noqa: E402
from subscribe_alerts import format_alert # noqa: E402
from cleanup_pending_convert import cleanup # noqa: E402
from app.repository.convert_repository import ConvertRepository # noqa: E402
from app.repository.convert_request_repository import ConvertRequestRepository # noqa: E402
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.repository.risk_repository import RiskRepository # noqa: E402
from app.service.risk import alert_service # noqa: E402
from app.service.risk.alert_service import handle_alert # noqa: E402
from app.service.risk.engine import process_trade_event # noqa: E402
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()
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES"
" ('C1', '张某某', 40, 1), ('C2', '李四', 35, 1)"
)
)
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type)"
" VALUES ('P1', '测试混合基金', 'R3', 'mixed')"
)
)
conn.execute(
text(
"INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold,"
" source, list_version, is_active) VALUES ('PEP-1', 'pep', '李四', 0.85, 'mock', 'v1', 1)"
)
)
core = CoreReadOnlyRepository(engine=engine)
repo = RiskRepository(engine=engine)
pub = FakePublisher()
alert_service.set_publisher(pub)
yield core, repo, pub, engine
alert_service.set_publisher(None)
engine.dispose()
def _seed_trade(conn, trade_id, amount, customer="C1", at=datetime(2026, 9, 6, 14, 0, 0)):
"""直接落 core_trade 不调引擎(engine_error 补偿场景:交易已成立、预警缺失)。"""
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount,"
" trade_status, traded_at) VALUES (:tid, :cid, 'P1', 'subscribe', :amt, 'confirmed', :at)"
),
{"tid": trade_id, "cid": customer, "amt": amount, "at": at},
)
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_rebuild_creates_alert_and_publishes(env):
core, repo, pub, engine = env
with engine.begin() as conn:
_seed_trade(conn, "TRD-TEST-RB1", "600000")
out = rebuild_trade("TRD-TEST-RB1", core, repo)
assert out["state"] == "rebuilt"
assert out["triggered_rules"] == ["RISK-001", "RISK-002"]
assert len(out["alert_ids"]) == 1 and out["aml_hit"] is False
alert = repo.get_alert(out["alert_ids"][0])
assert alert["status"] == "pending_review" and alert["risk_score"] == 70
(channel, _), = pub.messages
assert channel == "risk:pub:alert"
assert _counts(engine, "audit_log", "decision='alert_created'") == 1 # P-05 留痕链
def test_rebuild_idempotent_skips_second_run(env):
core, repo, pub, engine = env
with engine.begin() as conn:
_seed_trade(conn, "TRD-TEST-RB2", "600000")
first = rebuild_trade("TRD-TEST-RB2", core, repo)
second = rebuild_trade("TRD-TEST-RB2", core, repo)
assert first["state"] == "rebuilt" and second["state"] == "skipped"
assert second["alert_ids"] == first["alert_ids"]
assert _counts(engine, "risk_alert") == 1
assert len(repo.get_alert(first["alert_ids"][0])["payload"]["events"]) == 1
assert len(pub.messages) == 1 # 重放不重复推送
def test_rebuild_missing_trade_touches_nothing(env):
core, repo, pub, engine = env
out = rebuild_trade("TRD-NO-SUCH", core, repo)
assert out["state"] == "missing" and out["alert_ids"] == []
assert _counts(engine, "risk_alert") == 0
assert _counts(engine, "audit_log") == 0
assert pub.messages == []
def test_rebuild_aml_hit_then_idempotent(env):
"""aml 单幂等是 LIKE 检查的关键价值:record_aml_alert 本身无去重,重放防二次出单。"""
core, repo, pub, engine = env
with engine.begin() as conn:
_seed_trade(conn, "TRD-TEST-RB3", "1000", customer="C2")
first = rebuild_trade("TRD-TEST-RB3", core, repo)
assert first["state"] == "rebuilt" and first["aml_hit"] is True
aml_ids = [
aid
for aid in first["alert_ids"]
if repo.get_alert(aid)["alert_type"] == "aml"
]
assert aml_ids
second = rebuild_trade("TRD-TEST-RB3", core, repo)
assert second["state"] == "skipped" and second["alert_ids"] == first["alert_ids"]
assert _counts(engine, "risk_alert", "alert_type='aml'") == 1
def test_rebuild_non_first_trade_of_merged_alert_skips(env):
"""B8 复审口径(P2-2):幂等保障依赖 LIKE 对 events[n](n≥1)命中——
同日第二笔 append 进同单后 rebuild,必须 skip 且不重复 append。"""
core, repo, pub, engine = env
with engine.begin() as conn:
_seed_trade(conn, "TRD-TEST-RB4", "600000")
_seed_trade(conn, "TRD-TEST-RB5", "600000", at=datetime(2026, 9, 6, 15, 0, 0))
first = rebuild_trade("TRD-TEST-RB4", core, repo)
assert first["state"] == "rebuilt"
# 第二笔走正常引擎路径(等价网关同步调用)→ append 进同单
process_trade_event(core.get_trade_by_id("TRD-TEST-RB5"), core_ro=core, risk_repo=repo)
merged_id = first["alert_ids"][0]
assert len(repo.get_alert(merged_id)["payload"]["events"]) == 2
second = rebuild_trade("TRD-TEST-RB5", core, repo)
assert second["state"] == "skipped" and second["alert_ids"] == [merged_id]
assert len(repo.get_alert(merged_id)["payload"]["events"]) == 2 # 不重复 append
def test_rebuild_after_disposal_still_skips(env):
"""已处置单仍 skip(重放不得绕过处置结论,评审 P3-7)。"""
core, repo, pub, engine = env
with engine.begin() as conn:
_seed_trade(conn, "TRD-TEST-RB6", "600000")
out = rebuild_trade("TRD-TEST-RB6", core, repo)
aid = out["alert_ids"][0]
handle_alert(aid, "confirmed_normal", "STAFF-R1", risk_repo=repo)
again = rebuild_trade("TRD-TEST-RB6", core, repo)
assert again["state"] == "skipped" and again["alert_ids"] == [aid]
assert _counts(engine, "risk_alert") == 1
def test_rebuild_warns_on_engine_error_middle_state(env):
"""P2-1:首张单落库后 engine_error 中断(部分失败中间态)→ skip + warning 提示人工核对。"""
core, repo, pub, engine = env
with engine.begin() as conn:
_seed_trade(conn, "TRD-TEST-RB7", "600000")
out = rebuild_trade("TRD-TEST-RB7", core, repo)
assert "warning" not in out # 正常补偿无警示
# 模拟中断留痕:同一笔再走一遍"交易已成立但引擎异常"的审计(如 aml 单缺失场景)
repo.insert_audit_log(
{
"trace_id": "tr-x", "event_type": "trade_request", "agent_type": "platform",
"actor_id": "SYSTEM", "customer_id": "C1", "rule_id": None,
"input_summary": {"trade_id": "TRD-TEST-RB7", "error_stage": "process_trade_event"},
"decision": "risk_engine_error", "risk_score": None,
"handler_id": None, "handler_result": None, "handler_comment": None,
}
)
again = rebuild_trade("TRD-TEST-RB7", core, repo)
assert again["state"] == "skipped"
assert "人工核对" in again["warning"]
def test_format_alert_renders_payload_fields():
line = format_alert(
{
"alert_id": "ALT-20260906-ABC",
"alert_type": "aml",
"customer_id_mask": "CUST-9**",
"risk_score": 95,
"trace_id": "tr-1",
"notify_role": ["risk_officer", "compliance"],
}
)
for frag in ("aml", "ALT-20260906-ABC", "score=95", "CUST-9**", "tr-1",
"risk_officer,compliance"):
assert frag in line
assert "CUST-9527" not in line # payload 只有脱敏掩码,无原始 id
assert format_alert({"raw": "not-a-json-dict"}) == "[raw] not-a-json-dict"
# ---------- T-12 · rebuild_alerts --convert-group 分派与接线 ----------
def test_convert_group_delegates_to_service(monkeypatch):
"""`--convert-group` 只是薄封装:所有逻辑必须落在 compensate_convert(不复制实现)。"""
seen: dict = {}
def fake(group_id, **kwargs):
seen["gid"] = group_id
seen["kwargs"] = kwargs
return {"state": "skipped", "alert_ids": ["ALT-9"]}
monkeypatch.setattr(rebuild_alerts, "compensate_convert", fake)
out = rebuild_convert_group("CNV-T12-1", "CORE", "REPO", "CREPO")
assert out["state"] == "skipped"
assert seen["gid"] == "CNV-T12-1"
assert seen["kwargs"] == {"core_ro": "CORE", "risk_repo": "REPO", "convert_repo": "CREPO"}
def test_convert_group_missing_is_reported_without_writes(env):
"""未知 group(Core 侧无流水)→ missing,且不写任何东西。"""
core, repo, pub, engine = env
out = rebuild_convert_group("CNV-T12-NOPE", core, repo, ConvertRepository(engine=engine))
assert out["state"] == "missing" and out["trade_count"] == 0
assert _counts(engine, "risk_alert") == 0
assert _counts(engine, "audit_log") == 0
def test_cli_rejects_convert_group_with_trade_ids(monkeypatch):
"""`--convert-group` 与 trade_id 位置参数互斥(argparse 用法错误 → 退出码 2)。"""
monkeypatch.setattr(
sys, "argv", ["rebuild_alerts.py", "TRD-X", "--convert-group", "CNV-Y"]
)
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 2
def test_cli_requires_one_of_the_two_forms(monkeypatch):
"""两者都不给 → 用法错误(不是静默什么都不做)。"""
monkeypatch.setattr(sys, "argv", ["rebuild_alerts.py"])
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 2
# ---------- T-12 · cleanup_pending_convert:超 SLA 在途受理单 → expired ----------
# (T+1 模型:扫描对象 = Core 库 `core_convert_request`,v0.x 的 agent 库 pending 占位口径已废)
def _seed_calendar_allopen(engine, *, days_back: int = 90) -> None:
"""插「每天都开市」的日历(today 往前 days_back 天):交易日 = 自然日,边界可精确推算。
测试须显式建日历走**真实路径**(R-c(2):不得用日历缺失的降级路径充当覆盖率)。
"""
from datetime import date as _date, timedelta as _td
today = _date.today()
with engine.begin() as conn:
for i in range(days_back + 1):
conn.execute(
text(
"INSERT INTO core_trade_calendar (cal_date, is_open, remark)"
" VALUES (:d, 1, 't')"
),
{"d": today - _td(days=i)},
)
def _seed_convert_request(engine, rows) -> None:
"""rows: (group_id, status, requested_at) —— 在途/终态受理单直接落 Core 表。"""
with engine.begin() as conn:
for gid, status, requested in rows:
conn.execute(
text(
"INSERT INTO core_convert_request (convert_group_id, customer_id,"
" from_product_id, to_product_id, qty, status, requested_at)"
" VALUES (:g, 'CUST-T12S', 'PA', 'PB', 1000.0, :s, :r)"
),
{"g": gid, "s": status, "r": requested},
)
def _request_status_map(engine) -> dict:
with engine.connect() as conn:
return {
r["convert_group_id"]: r["status"]
for r in conn.execute(
text("SELECT convert_group_id, status FROM core_convert_request")
).mappings()
}
def _t12_cleanup(engine, days: int, **kw) -> dict:
"""统一调用形态:新签名 `cleanup(request_repo, core, days, ...)`。"""
return cleanup(
ConvertRequestRepository(engine=engine),
CoreReadOnlyRepository(engine=engine),
days,
**kw,
)
def test_cleanup_expires_only_overdue_inflight(sqlite_engine):
"""只动超 SLA 的**在途态**(accepted/nav_pending):fresh / 终态一律不碰。
S2 标记不硬删:行仍在;**镜像不动**(T-4 契约:镜像不推 expired,
业务状态以 Core 6 态为准)。
"""
_seed_calendar_allopen(sqlite_engine)
overdue = datetime.now() - timedelta(days=10)
fresh = datetime.now() - timedelta(hours=1)
_seed_convert_request(
sqlite_engine,
[
("CNV-OLD", "accepted", overdue),
("CNV-NEW", "accepted", fresh),
("CNV-NAV", "nav_pending", overdue),
("CNV-DONE", "confirmed", overdue),
("CNV-CANC", "cancelled", overdue),
],
)
with sqlite_engine.begin() as conn: # 一行镜像留痕,断言 cleanup 不碰它
conn.execute(
text(
"INSERT INTO risk_convert_detail (convert_group_id, status, estimated,"
" created_at) VALUES ('CNV-OLD', 'pending', 0, :c)"
),
{"c": overdue},
)
summary = _t12_cleanup(sqlite_engine, 2)
assert summary["sla_days"] == 2 and summary["dry_run"] is False
assert summary["candidate_count"] == 2
assert summary["expired"] == ["CNV-OLD", "CNV-NAV"]
assert _request_status_map(sqlite_engine) == {
"CNV-OLD": "expired",
"CNV-NAV": "expired",
"CNV-NEW": "accepted",
"CNV-DONE": "confirmed",
"CNV-CANC": "cancelled",
}
# S2:标记不硬删 —— 行仍在(5 行一行不少)
assert _counts(sqlite_engine, "core_convert_request") == 5
# 镜像不被 cleanup 推状态(T-4 契约:业务状态以 Core 为准)
with sqlite_engine.connect() as conn:
mirror_status = conn.execute(
text("SELECT status FROM risk_convert_detail WHERE convert_group_id = 'CNV-OLD'")
).scalar_one()
assert mirror_status == "pending"
def test_cleanup_idempotent_second_run_finds_nothing(sqlite_engine):
"""复跑 → 候选为空(已 expired 的行不再进候选),行数不变。"""
_seed_calendar_allopen(sqlite_engine)
_seed_convert_request(
sqlite_engine,
[("CNV-OLD", "accepted", datetime.now() - timedelta(days=10))],
)
first = _t12_cleanup(sqlite_engine, 2)
second = _t12_cleanup(sqlite_engine, 2)
assert first["expired"] == ["CNV-OLD"]
assert second["candidate_count"] == 0 and second["expired"] == []
assert _request_status_map(sqlite_engine) == {"CNV-OLD": "expired"}
assert _counts(sqlite_engine, "core_convert_request") == 1
def test_cleanup_dry_run_writes_nothing(sqlite_engine):
"""--dry-run 只报告候选,状态不动。"""
_seed_calendar_allopen(sqlite_engine)
_seed_convert_request(
sqlite_engine,
[("CNV-OLD", "accepted", datetime.now() - timedelta(days=10))],
)
summary = _t12_cleanup(sqlite_engine, 2, dry_run=True)
assert summary["dry_run"] is True
assert summary["candidate_count"] == 1 and summary["expired_count"] == 0
assert summary["candidates"][0]["convert_group_id"] == "CNV-OLD"
assert _request_status_map(sqlite_engine) == {"CNV-OLD": "accepted"}
def test_cleanup_honours_custom_sla(sqlite_engine):
"""--days 覆盖 SLA:窗口宽度 = days 个交易日(全开市日历下交易日 = 自然日)。
cutoff = 当前业务日上推 days 个交易日(与 `_window_start` 同构):
days=2 → cutoff = today−2,受理日 today−3 的单超时、**边界日 today−2 的单保留**
(confirm_batch 当天仍会尝试确认它);days=3 → cutoff = today−3,边界外扩,
该单回到窗口内不再超时 —— SLA 越宽清得越少(方向断言防口径倒置)。
"""
_seed_calendar_allopen(sqlite_engine)
two_days_ago = datetime.now() - timedelta(days=2)
three_days_ago = datetime.now() - timedelta(days=3)
_seed_convert_request(
sqlite_engine,
[
("CNV-2D", "accepted", two_days_ago),
("CNV-3D", "accepted", three_days_ago),
],
)
narrow = _t12_cleanup(sqlite_engine, 2, dry_run=True)
assert [c["convert_group_id"] for c in narrow["candidates"]] == ["CNV-3D"]
wider = _t12_cleanup(sqlite_engine, 3, dry_run=True)
assert wider["candidate_count"] == 0 # SLA 放宽一天:3D 的单回到确认窗口内
def test_cleanup_window_matches_confirm_batch_window(sqlite_engine):
"""SLA 边界与确认批处理捞单窗口**同口径**(R-14 核心约束)。
行为化等价断言:受理日 = 窗口下界(边界日)的单 —— `confirm_batch` 的
`list_pending_by_biz_date` 仍能捞到(含下界),cleanup 也**不清**它;
受理日 = 下界前一交易日的单 —— 两边都排除。口径漂移在此变红。
"""
from datetime import date as _date
from app.service.convert.confirm_service import _window_start
from app.service.convert.trading_calendar import previous_biz_day
_seed_calendar_allopen(sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
today = _date.today()
current_biz = today if core.is_open(today) else previous_biz_day(today, core.is_open)
window_start = _window_start(core, current_biz) # confirm_batch 捞单窗口下界
day_before = previous_biz_day(window_start.date(), core.is_open)
_seed_convert_request(
sqlite_engine,
[
("CNV-EDGE", "accepted", datetime.combine(window_start.date(), datetime.min.time())),
("CNV-BEFORE", "accepted", datetime.combine(day_before, datetime.min.time())),
],
)
# confirm_batch 视角:边界日在窗口内(仍会被确认)、前一日已被窗口排除
rrepo = ConvertRequestRepository(engine=sqlite_engine)
scanned = {
r["convert_group_id"]
for r in rrepo.list_pending_by_biz_date(current_biz, window_start)
}
assert scanned == {"CNV-EDGE"}
# cleanup 视角:与窗口同口径 —— 只清窗口外的,边界日单保留
summary = _t12_cleanup(sqlite_engine, 2)
assert summary["expired"] == ["CNV-BEFORE"]
assert _request_status_map(sqlite_engine)["CNV-EDGE"] == "accepted"