feat: B9a 演示/运维脚本——subscribe_alerts(risk:pub:alert 订阅演示/连接自检/--duration) + rebuild_alerts(按 trade_id 幂等重放: find_alerts_by_trade payload LIKE 查已入单防重复出单与重复 append, 含 aml/已处置单) + core_ro.get_trade_by_id 只读扩展 + test_demo_scripts 5 例, 201 绿; 真库手工验证(出单→幂等 skip→missing exit1; 订阅端到端 trace 贯通)后现场清理; docs 同步(TODO/MEMORY/FLOW/开发计划)

This commit is contained in:
2026-09-07 00:43:25 +08:00
parent be44d569a4
commit fdf487dc32
9 changed files with 375 additions and 11 deletions
+152
View File
@@ -0,0 +1,152 @@
"""B9a 演示/运维脚本单测(开发计划 B9a · sqlite)。
rebuild_alerts:补偿重放出单并推送、幂等跳过(防重复 append/aml 重复出单)、
missing 不落库;subscribe_alerts:payload → 单行可读文本。
脚本目录非包,动态入 sys.path 后按模块名导入。
"""
import sys
from datetime import datetime
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"
sys.path.insert(0, str(DEMO_DIR))
from rebuild_alerts import rebuild_trade # noqa: E402
from subscribe_alerts import format_alert # noqa: E402
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.risk import alert_service
class FakePublisher:
def __init__(self):
self.messages = []
def publish(self, channel, payload):
self.messages.append((channel, payload))
@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"
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_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