470 lines
19 KiB
Python
470 lines
19 KiB
Python
"""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())
|