基金转换 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:
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user