"""T-12 真 MySQL 验证脚本:补偿脚本在真库/真账号下实跑 + DoD 逐条断言。 **为什么 sqlite 单测全绿还不够(T-12 视角)** 1. **`status='expired'` 的真库 ENUM 值域**:`risk_convert_detail.status` 在 MySQL 是 `ENUM('pending','completed','failed','cancelled','expired')`,写错值会**直接报错**; 而 sqlite 测试库该列是 `VARCHAR(16)`,**任何字符串都收**。故「cleanup 能写进 expired」 这件事必须在真库证明一次。 2. **`input_summary` 是真 JSON 列**:`has_engine_error_audit` 用 `input_summary LIKE '%gid%'` 做幂等/警示判定。sqlite 里它是 TEXT、必定能 LIKE;MySQL 的 JSON 列能否直接 LIKE 属方言行为,必须实跑确认(本脚本 E 组专测)。 3. **DATETIME(3) 的时间边界**:`list_expired_candidates` 的 `created_at < cutoff` 在 MySQL 毫秒精度列上是否按预期分流(超时进候选、未超时不进),sqlite 的字符串时间 给不了这个保证。 4. **端到端**:阶段二失败 → 补偿 → `completed` + 出单 + 主审计,走真事务、真账号。 **断言分组** A 待补偿态(阶段二 + 阶段 1.5 双失败)在真库如实落痕 B 超时 `pending` 占位 → `expired`(含 ENUM 值域 / 时间边界 / 幂等 / 标记不硬删) C `compensate_convert` 端到端:详情 `completed` + 1 张单 + 主审计 D 重复补偿 → `skipped`(不产生第二张单、不重复主审计) E 真 **JSON 列**上 `input_summary LIKE` 生效(`has_engine_error_audit`) F 清理后残留为零(自检) 用法: python scripts/dev/verify_convert_compensate.py 约定(与 T-6/T-7/T-8/T-11 脚本一致): - 用 **T12C 前缀**隔离数据(客户/产品/批次/group_id),跑完**两个库**(core + agent)全清; - 建/清数据走 `role="admin"`(需 DELETE);业务本身走 `convert_fund` 默认账号; - 固定 `trace_id = "T12C-VERIFY-TRACE"`,便于精准清理 `audit_log`; - 退出码 1 = 有断言不一致。 """ from __future__ import annotations import json import sys import uuid from datetime import date, datetime, timedelta from decimal import Decimal from pathlib import Path from unittest.mock import patch from sqlalchemy import text ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from app.config.settings import settings # noqa: E402 from app.repository.convert_repository import ConvertRepository # noqa: E402 from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402 from app.repository.risk_repository import RiskRepository # noqa: E402 from app.service.convert.convert_service import ( # noqa: E402 compensate_convert, convert_fund, ) from app.service.risk.rules import RiskThresholds # noqa: E402 from app.utils.db import dispose_engines, get_engine # noqa: E402 from app.utils.trace import new_trace # noqa: E402 CUSTOMER = "CUST-T12C" PROD_OUT = "PROD-T12CO" PROD_IN = "PROD-T12CI" COMPANY = "华夏模拟基金" TA = "TA-CN-001" TRADE_AT = datetime(2026, 9, 4, 10, 0, 0) TRADE_DATE = date(2026, 9, 4) NOW = TRADE_AT IN_NAV = Decimal("0.9500") OUT_RATE = Decimal("0.0030") IN_RATE = Decimal("0.0080") FEE_TIERS = [ (0, 7, "0.0150"), (7, 30, "0.0100"), (30, 180, "0.0050"), (180, 365, "0.0025"), (365, None, "0.0000"), ] CID_REQ = "T12C-COMP-0001" TRACE = "T12C-VERIFY-TRACE" #: 大额阈值调到 100000 —— 转出端约 412000,确保补偿时引擎**真出一张单**(可断言) TH = RiskThresholds( large_amount=Decimal("100000"), 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, # 持仓画像不参与(避免 RISK-006 干扰) ) _passed = 0 _failed = 0 #: 建/清数据用 admin 引擎(需 DELETE,R-e);在 main 中初始化 core_engine = None agent_engine = None def check(name: str, actual, expected) -> None: global _passed, _failed ok = actual == expected if ok: _passed += 1 else: _failed += 1 flag = "✅" if ok else "❌" print(f" {flag} {name}: 实际 {actual!r}" + ("" if ok else f" / 期望 {expected!r}")) def _t12c_id(prefix: str, now: datetime) -> str: """固定前缀 id 工厂:默认 `CNV-<日期>-` 与清理口径 `LIKE 'CNV-T12C%'` 不匹配, 重跑会留撞唯一键的残留(T-7 脚本踩过同款坑)。""" return f"{prefix}-T12C-{uuid.uuid4().hex[:8].upper()}" def q1(engine, sql: str, **params): with engine.connect() as conn: return conn.execute(text(sql), params).scalar_one_or_none() def rows(engine, sql: str, **params): with engine.connect() as conn: return [dict(r) for r in conn.execute(text(sql), params).mappings()] # ── 数据准备 / 清理 ───────────────────────────────────────────────── def cleanup(core_engine, agent_engine) -> None: """按 T12C 前缀清理两个库(含幂等重跑前置清理)。""" with core_engine.begin() as conn: for sql, params in [ ("DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE 'CNV-T12C%'", {}), ("DELETE FROM core_trade WHERE customer_id = :c", {"c": CUSTOMER}), ("DELETE FROM core_share_lot WHERE customer_id = :c", {"c": CUSTOMER}), ("DELETE FROM core_holding WHERE customer_id = :c", {"c": CUSTOMER}), ("DELETE FROM core_customer_risk WHERE customer_id = :c", {"c": CUSTOMER}), ("DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-T12C%'", {}), ("DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-T12C%'", {}), ("DELETE FROM core_product WHERE product_id LIKE 'PROD-T12C%'", {}), ("DELETE FROM core_customer WHERE customer_id = :c", {"c": CUSTOMER}), ]: conn.execute(text(sql), params) with agent_engine.begin() as conn: conn.execute( text( "DELETE FROM risk_convert_detail " "WHERE convert_group_id LIKE 'CNV-T12C%' OR client_request_id LIKE 'T12C-%'" ) ) conn.execute(text("DELETE FROM risk_alert WHERE customer_id = :c"), {"c": CUSTOMER}) conn.execute( text("DELETE FROM customer_profile_l3 WHERE customer_id = :c"), {"c": CUSTOMER} ) conn.execute(text("DELETE FROM audit_log WHERE trace_id LIKE 'T12C-VERIFY%'")) def seed(core_engine) -> None: with core_engine.begin() as conn: conn.execute( text( "INSERT INTO core_customer (customer_id, display_name, open_date) " "VALUES (:c, 'T12补偿真库验证', :d)" ), {"c": CUSTOMER, "d": TRADE_DATE}, ) conn.execute( text( "INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) " "VALUES (:c, 'C3', :t, :exp)" ), {"c": CUSTOMER, "t": TRADE_AT - timedelta(days=30), "exp": TRADE_AT + timedelta(days=300)}, ) for pid, name, ptype, rate in [ (PROD_OUT, "T12转出基金", "bond", OUT_RATE), (PROD_IN, "T12转入基金", "stock", IN_RATE), ]: conn.execute( text( "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, :n, 'R2', :t, 1, 1, :r, :co, :ta)" ), {"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA}, ) for mh, mh_max, rate in FEE_TIERS: conn.execute( text( "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": rate}, ) conn.execute( text( "INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) " "VALUES (:p, :nav, 0, :d)" ), {"p": PROD_IN, "nav": str(IN_NAV), "d": TRADE_DATE}, ) for lot_id, qty, nav, confirmed in [ ("LOT-T12C-A1", "400000", "1.0300", datetime(2025, 8, 1, 10, 0, 0)), ("LOT-T12C-A2", "100000", "1.0000", datetime(2026, 9, 1, 10, 0, 0)), ]: conn.execute( text( "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": qty, "nav": nav, "cat": confirmed}, ) conn.execute( text( "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount, " "market_value, pnl_pct, as_of) VALUES (:c, :p, 500000, 512000.00, 512000.00, 0, :d)" ), {"c": CUSTOMER, "p": PROD_OUT, "d": TRADE_DATE}, ) def _req(cid_req: str | None = CID_REQ) -> dict: return { "customer_id": CUSTOMER, "from_product_id": PROD_OUT, "to_product_id": PROD_IN, "qty": "120000", "client_request_id": cid_req, } def _boom_engine(out_trade, in_trade): raise RuntimeError("T12C 故意:阶段 1.5 引擎失败") # ── 断言主体 ──────────────────────────────────────────────────────── def build_pending_state(core, repo, crepo) -> str: """构造待补偿态:阶段二写详情失败 + 阶段 1.5 引擎失败,但 Core 侧两条流水已成立。""" print("\n[A] 待补偿态构造(阶段二 + 阶段 1.5 双失败)") with patch.object( ConvertRepository, "complete_convert", side_effect=RuntimeError("T12C 故意:阶段二失败") ): resp = convert_fund( _req(), core_ro=core, risk_repo=repo, convert_repo=crepo, thresholds=TH, now=NOW, engine_hook=_boom_engine, id_factory=_t12c_id, ) gid = resp["convert_group_id"] trades = rows( core_engine, "SELECT * FROM core_trade WHERE convert_group_id = :g", g=gid ) check("Core 侧两条流水已成立(交易不可回滚)", len(trades), 2) check( "两条流水 trade_type 齐备", sorted(t["trade_type"] for t in trades), ["redeem", "subscribe"], ) detail = rows( agent_engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid ) check("占位已置 failed(待补偿)", detail[0]["status"] if detail else None, "failed") check( "此时本客户无预警单", q1( agent_engine, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c", c=CUSTOMER, ), 0, ) check( "验收 17 前置:convert_detail_write_failed 审计存在", q1( agent_engine, "SELECT COUNT(*) FROM audit_log WHERE decision = 'convert_detail_write_failed' " "AND trace_id LIKE 'T12C-VERIFY%'", ), 1, ) check( "阶段 1.5 失败审计(decision='engine_error')存在", q1( agent_engine, "SELECT COUNT(*) FROM audit_log WHERE decision = 'engine_error' " "AND trace_id LIKE 'T12C-VERIFY%'", ), 1, ) return gid def check_cleanup(core, repo, crepo) -> None: """B 组:超时 pending → expired(ENUM 值域 / 时间边界 / 幂等 / 不硬删)。""" print("\n[B] 超时 pending 占位 → expired(cleanup_pending_convert 同款仓储路径)") orphan = f"CNV-T12C-ORPHAN-{uuid.uuid4().hex[:6].upper()}" crepo.insert_placeholder(orphan, f"T12C-ORPHAN-{uuid.uuid4().hex[:6].upper()}") cand = [r["convert_group_id"] for r in crepo.list_expired_candidates(24)] check("刚落下的 pending 不算超时(时间边界下界)", orphan in cand, False) # 回拨 created_at 到 30h 前 → 应进候选 with agent_engine.begin() as conn: conn.execute( text( "UPDATE risk_convert_detail SET created_at = :t WHERE convert_group_id = :g" ), {"t": datetime.now() - timedelta(hours=30), "g": orphan}, ) cand = [r["convert_group_id"] for r in crepo.list_expired_candidates(24)] check("回拨 30h 后进候选(时间边界上界)", orphan in cand, True) crepo.mark_expired(orphan) check( "真库 ENUM 接受 status='expired'", q1( agent_engine, "SELECT status FROM risk_convert_detail WHERE convert_group_id = :g", g=orphan, ), "expired", ) cand = [r["convert_group_id"] for r in crepo.list_expired_candidates(24)] check("复跑不再进候选(幂等)", orphan in cand, False) check( "S2 标记不硬删:行仍在", q1( agent_engine, "SELECT COUNT(*) FROM risk_convert_detail WHERE convert_group_id = :g", g=orphan, ), 1, ) def check_compensate(gid: str, core, repo, crepo) -> None: """C/D/E 组:补偿端到端 + 幂等 + 真 JSON 列 LIKE。""" print("\n[C] compensate_convert 端到端(详情 + 预警一次补齐)") out = compensate_convert( gid, core_ro=core, risk_repo=repo, convert_repo=crepo, thresholds=TH, now=NOW, ) check("state", out["state"], "rebuilt") check("detail 分支", out["detail"], "rebuilt") check("engine 分支", out["engine"], "rebuilt") check("补偿后出一张单", len(out["alert_ids"]), 1) check("命中 RISK-001", "RISK-001" in out["triggered_rules"], True) detail = rows( agent_engine, "SELECT * FROM risk_convert_detail WHERE convert_group_id = :g", g=gid )[0] check("详情置 completed", detail["status"], "completed") check("回填 out_trade_id 与 Core 一致", detail["out_trade_id"], out["out_trade_id"]) check("回填 in_trade_id 与 Core 一致", detail["in_trade_id"], out["in_trade_id"]) check("回填 nav 非空", detail["nav"] is not None, True) check("回填 nav_date 非空", detail["nav_date"] is not None, True) check( "主审计(convert_accepted)已补", q1( agent_engine, "SELECT COUNT(*) FROM audit_log WHERE decision = 'convert_accepted' " "AND trace_id LIKE 'T12C-VERIFY%'", ), 1, ) alerts = rows(agent_engine, "SELECT * FROM risk_alert WHERE customer_id = :c", c=CUSTOMER) check("预警单 1 张", len(alerts), 1) payload = json.loads(alerts[0]["payload"]) if isinstance(alerts[0]["payload"], str) \ else alerts[0]["payload"] check("一张单两条事件(转出 + 转入,真 JSON 列反解)", len(payload["events"]), 2) print("\n[D] 重复补偿 → skipped(幂等,不产生第二张单)") again = compensate_convert( gid, core_ro=core, risk_repo=repo, convert_repo=crepo, thresholds=TH, now=NOW ) check("state", again["state"], "skipped") check("detail 分支", again["detail"], "already_completed") check("engine 分支", again["engine"], "skipped") check("alert_ids 与首次一致", again["alert_ids"], out["alert_ids"]) check( "预警单仍只 1 张", q1(agent_engine, "SELECT COUNT(*) FROM risk_alert WHERE customer_id = :c", c=CUSTOMER), 1, ) check( "主审计不重复", q1( agent_engine, "SELECT COUNT(*) FROM audit_log WHERE decision = 'convert_accepted' " "AND trace_id LIKE 'T12C-VERIFY%'", ), 1, ) check("幂等跳过时附人工核对提示", "人工核对" in (again.get("warning") or ""), True) print("\n[E] 真 JSON 列上 input_summary LIKE 生效(has_engine_error_audit)") col_type = q1( agent_engine, "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = 'audit_log' AND COLUMN_NAME = 'input_summary'", ) check("前提:input_summary 真库列类型为 json", str(col_type).lower(), "json") check( "按 group_id 命中(sqlite 是 TEXT,此断言只在真库有意义)", repo.has_engine_error_audit(gid, decision="engine_error"), True, ) check( "决策码不匹配则不命中(证明判定真的按 decision 过滤)", repo.has_engine_error_audit(gid, decision="risk_engine_error"), False, ) def main() -> int: global core_engine, agent_engine core_engine = get_engine(settings.mysql_core_database, "admin") agent_engine = get_engine(settings.mysql_database, "admin") print("T-12 真库验证:补偿脚本(详情 + 预警)+ 超时占位清理") print(f"客户={CUSTOMER} 产品={PROD_OUT}/{PROD_IN} 交易日={TRADE_DATE}") cleanup(core_engine, agent_engine) new_trace(TRACE) # 固定 trace:审计行才能按 'T12C-VERIFY%' 精准清理与断言 try: seed(core_engine) core = CoreReadOnlyRepository(engine=core_engine) # 业务路径用生产默认账号(ro 读 / rw 写),仅建清数据用 admin core_ro = CoreReadOnlyRepository(engine=get_engine(settings.mysql_core_database, "ro")) repo = RiskRepository(engine=get_engine(settings.mysql_database, "rw")) crepo = ConvertRepository(engine=get_engine(settings.mysql_database, "rw")) gid = build_pending_state(core_ro, repo, crepo) check_cleanup(core_ro, repo, crepo) check_compensate(gid, core_ro, repo, crepo) finally: cleanup(core_engine, agent_engine) left = q1( core_engine, "SELECT (SELECT COUNT(*) FROM core_trade WHERE customer_id LIKE 'CUST-T12C%') " "+ (SELECT COUNT(*) FROM core_share_lot WHERE customer_id LIKE 'CUST-T12C%') " "+ (SELECT COUNT(*) FROM core_holding WHERE customer_id LIKE 'CUST-T12C%') " "+ (SELECT COUNT(*) FROM core_product WHERE product_id LIKE 'PROD-T12C%') " "+ (SELECT COUNT(*) FROM core_customer WHERE customer_id LIKE 'CUST-T12C%')", ) + q1( agent_engine, "SELECT (SELECT COUNT(*) FROM risk_convert_detail WHERE convert_group_id LIKE 'CNV-T12C%') " "+ (SELECT COUNT(*) FROM risk_alert WHERE customer_id = 'CUST-T12C') " "+ (SELECT COUNT(*) FROM audit_log WHERE trace_id LIKE 'T12C-VERIFY%')", ) print(f"\n清理后残留行数:{left}") dispose_engines() print(f"\n结果:{_passed} 通过 / {_failed} 失败") return 1 if _failed else 0 if __name__ == "__main__": raise SystemExit(main())