基金转换 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
+103
View File
@@ -0,0 +1,103 @@
"""超时 `pending` 转换占位清理(T-12 · 架构 §5.4 · PRD §7.1 I-3)。
背景:convert 阶段零先落一行 `risk_convert_detail(status='pending')` 占位,阶段二
回写 `completed`。若进程在阶段零与阶段一之间挂掉,这行占位会**永久滞留**,既污染
巡检又让人误判「有一笔转换在飞」。本脚本按 SLA(`convert_compensate_sla_hours`,
默认 24h)把超时占位置 `status='expired'`。
**标记不硬删(评审 S2)**:只 UPDATE 状态,**绝不 DELETE** —— 留痕供对账
(`expired` 是建表即含的 ENUM 值,无需 ALTER)。这与
`ConvertRepository.mark_expired` 是**同一份实现**,本脚本不复制 SQL。
用法与退出码:
python scripts/agent/cleanup_pending_convert.py # 按 SLA 24h 清理
python scripts/agent/cleanup_pending_convert.py --hours 1 # 覆盖 SLA(演练/测试)
python scripts/agent/cleanup_pending_convert.py --dry-run # 只报告不写库
0 = 清理完成(含「无候选」);1 = MySQL 不可达或写库失败。
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# ① sys.path 引导项目根(rebuild_alerts / escalation_scan 先例)
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.utils.trace import new_trace # noqa: E402
def cleanup(repo: ConvertRepository, hours: int, *, dry_run: bool = False) -> dict:
"""扫出超 `hours` 的 pending 占位并置 `expired`;返回 JSON 友好摘要。
幂等:`mark_expired` 只把 `pending` 行改状态,已 `expired` 的行**不再进候选**
(`list_expired_candidates` 硬编码 `status='pending'`),故重复执行第二次为空操作。
"""
candidates = repo.list_expired_candidates(hours)
expired: list[str] = []
if not dry_run:
for row in candidates:
group_id = str(row["convert_group_id"])
repo.mark_expired(group_id)
expired.append(group_id)
return {
"sla_hours": hours,
"dry_run": dry_run,
"candidate_count": len(candidates),
"expired_count": len(expired),
"expired": expired,
"candidates": [
{
"convert_group_id": str(r["convert_group_id"]),
"client_request_id": r.get("client_request_id"),
"created_at": r.get("created_at"),
}
for r in candidates
],
}
def main() -> int:
parser = argparse.ArgumentParser(
description="超时 pending 转换占位 → status='expired'(标记不硬删,S2)"
)
parser.add_argument(
"--hours",
type=int,
default=None,
help=f"覆盖 SLA 小时数(缺省取 settings.convert_compensate_sla_hours="
f"{settings.convert_compensate_sla_hours})",
)
parser.add_argument("--dry-run", action="store_true", help="只报告候选,不写库")
args = parser.parse_args()
hours = args.hours if args.hours is not None else settings.convert_compensate_sla_hours
new_trace()
repo = ConvertRepository()
# 连接自检(零写入探针)
try:
repo.list_expired_candidates(hours)
except Exception as exc:
print(f"MySQL 不可达:{exc}", file=sys.stderr)
print("请确认本机 MySQL 服务已启动(见 FLOW §0 本机状态)。", file=sys.stderr)
return 1
try:
summary = cleanup(repo, hours, dry_run=args.dry_run)
except Exception as exc: # noqa: BLE001
print(f"清理失败:{exc}", file=sys.stderr)
return 1
print(json.dumps(summary, ensure_ascii=False, default=str, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
+81 -2
View File
@@ -9,6 +9,13 @@
不重复出单、不重复 append 事件。**勿并行运行多个本脚本实例**(聚合锁为进程内
锁,跨进程并发重放同一 trade_id 无防护)。
**基金转换补偿(T-12 · PRD §7.1 / 架构 §5.4)**:`--convert-group CNV-xxx` 从
Core 侧(`core_trade` 两条流水 + `core_convert_lot_detail` 的 `nav`/`nav_date`)
补偿一次转换的**详情 + 预警**两件事,全部委托 `convert_service.compensate_convert`
(锁在 service 内,锁键 `convert:rerun:{gid}`,与客户端带同键重试共用 →
人工补跑与客户端重试不会并发重复出单)。幂等锚点为**转出端 out_trade_id**:
一次转换有两条流水,只认转出端才不重复出单(评审 Q4)。
可复现性与局限:
- RISK-004/005 规则窗口以 core_trade.traded_at 为事件时点,不受脚本执行时刻影响;
聚合锚点(同日 pending 单查找)按执行日——跨日补放历史交易时事件并入执行日
@@ -19,6 +26,11 @@
用法:
python scripts/demo/rebuild_alerts.py TRD-20260907-AB12CD34 [更多 trade_id ...]
python scripts/demo/rebuild_alerts.py --convert-group CNV-20260907-AB12CD34
退出码:0 = 全部成功/幂等跳过;1 = 有 trade_id 未找到,或 convert 组 Core 侧
流水不足(missing);3 = convert 组未抢到执行权(locked,稍后重试即可)。
(2 保留给 argparse 的参数用法错误。)
"""
from __future__ import annotations
@@ -31,11 +43,14 @@ from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
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 compensate_convert # noqa: E402
from app.service.risk.engine import process_trade_event # noqa: E402
def rebuild_trade(
trade_id: str,
core: CoreReadOnlyRepository,
@@ -74,11 +89,41 @@ def rebuild_trade(
}
def rebuild_convert_group(
group_id: str,
core: CoreReadOnlyRepository,
repo: RiskRepository,
crepo: ConvertRepository | None = None,
) -> dict[str, Any]:
"""按 convert_group_id 补偿一次转换(CLI 与单测共用入口)。
薄封装:全部逻辑在 `convert_service.compensate_convert`(锁 / 详情补写 / 预警
补偿都是那**一份**实现,本脚本不复制任何一条 —— 自检第 13 问)。
返回 `{"state": "rebuilt"|"skipped"|"missing"|"locked", ...}`。
"""
return compensate_convert(
group_id, core_ro=core, risk_repo=repo, convert_repo=crepo
)
def main() -> None:
parser = argparse.ArgumentParser(description="按 trade_id 幂等重放风控引擎(引擎异常补偿)")
parser.add_argument("trade_ids", nargs="+", help="core_trade 中的 trade_id,可一次多笔")
parser = argparse.ArgumentParser(
description="按 trade_id 幂等重放风控引擎;或按 --convert-group 补偿一次基金转换"
)
parser.add_argument("trade_ids", nargs="*", help="core_trade 中的 trade_id,可一次多笔")
parser.add_argument(
"--convert-group",
metavar="CNV-xxx",
help="按 convert_group_id 从 Core 侧补偿一次转换(详情 + 预警),与 trade_id 互斥",
)
args = parser.parse_args()
if args.convert_group:
if args.trade_ids:
parser.error("--convert-group 与 trade_id 位置参数互斥,请择一使用")
elif not args.trade_ids:
parser.error("请给出 trade_id 位置参数,或使用 --convert-group CNV-xxx")
core = CoreReadOnlyRepository()
repo = RiskRepository()
@@ -91,6 +136,40 @@ def main() -> None:
print("请确认本机 MySQL 服务已启动(见 FLOW §0 本机状态)。", file=sys.stderr)
raise SystemExit(1)
if args.convert_group:
out = rebuild_convert_group(args.convert_group, core, repo, ConvertRepository())
gid = args.convert_group
state = out["state"]
if state == "rebuilt":
rules = ",".join(out.get("triggered_rules") or []) or "-"
print(
f"[rebuilt] {gid}: detail={out['detail']} engine={out['engine']} "
f"rules={rules} alerts={out['alert_ids']} "
f"out={out['out_trade_id']} in={out['in_trade_id']}"
)
elif state == "skipped":
print(
f"[skipped] {gid}: 详情已 completed、预警单已存在 "
f"{out['alert_ids']}(幂等跳过)"
)
else:
print(
f"[{state}] {gid}: "
+ (
f"Core 侧流水不足(实得 {out.get('trade_count')} 条,需 2 条)"
if state == "missing"
else "未抢到 convert:rerun 执行权,稍后重试"
),
file=sys.stderr,
)
if out.get("warning"):
print(f"[warning] {gid}: {out['warning']}", file=sys.stderr)
if state == "missing":
raise SystemExit(1)
if state == "locked":
raise SystemExit(3)
return
missing = 0
for tid in args.trade_ids:
out = rebuild_trade(tid, core, repo)
+469
View File
@@ -0,0 +1,469 @@
"""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-<日期>-<uuid>` 与清理口径 `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())