## 结论:risk_action_service 没问题,我先前是误判
esolve(完成结案)写全了 status="已结案" + closed_at + handle_result(risk_action_service.py:105-107),
exclude 同样(:67-71)。我报的"结案没写 closed_at"看到的那条 ALDEMO0003
其实是**种子预置样本**,不是我以为的自己创建的那条 —— 我的冒烟脚本用
ALDEMO{时分秒} 命名,**撞上了种子的 ALDEMO0001-0003**,"已存在则跳过"把创建挡掉,
随后处置全 409,串起来就像"结案没写时间"。
真正的问题在种子数据,两处:
1) **状态口径与代码不一致**:ALDEMO0003 写成 status="已关闭",而
isk_action_service 里**没有任何动作会产生这个状态**(状态机只有
待处理 / 调查中 / 已排除 / 已结案)。演示时会被当成系统行为,排查时又查不到出处。
按它的 close_reason 语义(低风险 + 已核验为正常交易),它就是**关闭误报** → 改为"已排除"。
2) **终态缺 closed_at**:那条有 close_reason 却没有 closed_at,自相矛盾;
而且
isk_repository 按 closed_at 统计"当日结案",缺了它会被漏掉。
INSERT 补上该列,样本用 closed_at_offset_minutes 给值。
## 顺带修掉冒烟脚本的撞号
预警编号前缀 ALDEMO → E2E,注释里写明原因 —— 免得下次又操作到种子样本、
还把现象当成新 bug。
## 验证
- ALDEMO0003 现为 status=已排除 + closed_at 有值
- 库里所有终态预警(已排除/已结案)都带 closed_at;进行中的(调查中)不带 —— 口径一致
- 自己创建的两条走 resolve 后 status=已结案 且 closed_at/handle_result 都有值
- 冒烟重跑:40/40 通过,新造预警编号 E2E134351,处置闭环三段全 PASS
门禁:ruff 通过 / mypy 250 文件 0 错 / 单元+契约 1381 passed 2 skipped。
192 lines
8.0 KiB
Python
192 lines
8.0 KiB
Python
"""造风控演示数据:让概览 / 查询 / 证据三个只读工具都能返回真实内容。
|
||
|
||
**为什么需要**:配置与 RBAC 补齐、时区修好之后,`fin_risk_alert` 表是空的 ——
|
||
概览只能显示"未闭环预警总量 0",看不出任何真实内容,没法验收。
|
||
|
||
**造什么**:3 条预警,刻意覆盖三个工具各自的读路径:
|
||
|
||
1. **高危 + 待处理 + 三条规则 + 完整证据快照** —— 概览的高危计数、查询的高危筛选、
|
||
证据工具的证据链,三条路径都靠它。
|
||
2. **中危 + 调查中 + 两条规则** —— 让"未闭环"统计里出现"调查中"。
|
||
3. **低危 + 已闭环** —— 验证"未闭环"筛选真的把它排除掉(这是上一批修复里
|
||
`OPEN_STATUSES` 的用途)。
|
||
|
||
**字段取值不自行发明**,全部照 `risk_scan_service.py:312-333` 的 `_build_alert` 抄:
|
||
状态用 `OPEN_STATUSES = ("待处理", "调查中")`(见 `risk_action_service.py:17`)、
|
||
`ack_status` 用"未确认"、`alert_level` 用"高/中/低"。
|
||
|
||
**两个注意点**:
|
||
|
||
- `fin_risk_alert.id` **不是自增**(见建表语句),所以这里手工生成 id;
|
||
- 时间一律按库内约定存 **UTC naive**(`app/infrastructure/db.py:15-21`)。
|
||
|
||
幂等:按 `alert_no` 判断,已存在则跳过。
|
||
|
||
用法:python tools/seed_risk_alert_demo_data.py
|
||
"""
|
||
|
||
import asyncio
|
||
import datetime as dt
|
||
import json
|
||
import sys
|
||
from typing import Any
|
||
|
||
import asyncmy
|
||
|
||
from app.core.config import get_settings
|
||
|
||
CUSTOMER_ID = 9001 # 已有的画像客户(fin_customer_profile 里那一条)
|
||
HANDLER_ID = 9002 # risk_operator
|
||
ID_BASE = 9_000_000_000_000_001
|
||
|
||
ALERTS: tuple[dict[str, Any], ...] = (
|
||
{
|
||
"id": ID_BASE,
|
||
"alert_no": "ALDEMO0001",
|
||
"alert_type": "大额频繁交易",
|
||
"alert_level": "高",
|
||
"trigger_rule_codes": ["RW-007", "RW-002", "RW-012"],
|
||
"evidence_summary": (
|
||
"客户近 24 小时内 5 笔申购合计 486,000 元,单笔最大 200,000 元,"
|
||
"为本人近 90 日均值的 6.2 倍;收款账户与历史常用账户不一致。"
|
||
),
|
||
"evidence_snapshot": {
|
||
"product_id": 7001,
|
||
"product_name": "南方季季盈90天",
|
||
"transaction_count": 5,
|
||
"total_amount": "486000.00",
|
||
"max_amount": "200000.00",
|
||
"average_amount": "78387.10",
|
||
"ratio": "6.20",
|
||
"window_hours": 24,
|
||
"customer_age": 34,
|
||
"login_ip_regions": ["广东深圳", "香港"],
|
||
"device_changed": True,
|
||
"open_status": "未闭环",
|
||
},
|
||
"priority_score": 95,
|
||
"event_status": "刚刚发生",
|
||
"status": "待处理",
|
||
"ack_status": "未确认",
|
||
"due_at_offset_minutes": 30,
|
||
"is_escalated": 0,
|
||
},
|
||
{
|
||
"id": ID_BASE + 1,
|
||
"alert_no": "ALDEMO0002",
|
||
"alert_type": "非正常时段大额操作",
|
||
"alert_level": "高",
|
||
"trigger_rule_codes": ["RW-015", "RW-003"],
|
||
"evidence_summary": (
|
||
"成交时间对应北京时间 02:17(凌晨),金额 88,000 元;"
|
||
"该客户近 30 天无凌晨交易记录,且未匹配到有效定投工单。"
|
||
),
|
||
"evidence_snapshot": {
|
||
"product_id": 7002,
|
||
"product_name": "南方稳健增利180天",
|
||
"amount": "88000.00",
|
||
"beijing_hour": 2,
|
||
"utc_confirmed_at": "2026-09-09T18:17:00",
|
||
"matched_work_order": None,
|
||
"channel": "APP",
|
||
"device_changed": False,
|
||
"open_status": "未闭环",
|
||
},
|
||
"priority_score": 88,
|
||
"event_status": "盘后预警",
|
||
"status": "调查中",
|
||
"ack_status": "已确认",
|
||
"due_at_offset_minutes": 30,
|
||
"is_escalated": 1,
|
||
},
|
||
{
|
||
"id": ID_BASE + 2,
|
||
"alert_no": "ALDEMO0003",
|
||
"alert_type": "低风险频繁交易初筛",
|
||
"alert_level": "低",
|
||
"trigger_rule_codes": ["RW-018"],
|
||
"evidence_summary": "命中低优先级频繁交易初筛,交易来自已核验的自动定投工单。",
|
||
"evidence_snapshot": {
|
||
"product_id": 7001,
|
||
"product_name": "南方季季盈90天",
|
||
"matched_work_order": "WO-DEMO-0007",
|
||
"channel": "自动定投",
|
||
"open_status": "已闭环",
|
||
},
|
||
"priority_score": 20,
|
||
"event_status": "盘后预警",
|
||
# ⚠️ 状态必须是**代码能产出**的那几个(待处理 / 调查中 / 已排除 / 已结案)。
|
||
# 这里原写作「已关闭」,而 `risk_action_service` 里没有任何动作会产生它 ——
|
||
# 演示时会被当成系统行为、排查时又查不到出处。按 close_reason 的语义
|
||
# (低风险 + 已核验为正常交易),它其实就是「关闭误报」。
|
||
"status": "已排除",
|
||
"ack_status": "已确认",
|
||
"due_at_offset_minutes": None,
|
||
"is_escalated": 0,
|
||
"close_reason": "已核验为本人自动定投计划,属正常交易。",
|
||
# 终态样本必须带关闭时间:只有 close_reason 却没有 closed_at 是自相矛盾的,
|
||
# 而且 `risk_repository` 按 closed_at 统计"当日结案",缺了它这条会被漏掉。
|
||
"closed_at_offset_minutes": -120,
|
||
},
|
||
)
|
||
|
||
|
||
async def main() -> int:
|
||
settings = get_settings()
|
||
dsn = settings.mysql_dsn.split("://", 1)[1]
|
||
credentials, location = dsn.split("@", 1)
|
||
user, password = credentials.split(":", 1)
|
||
host_port, database = location.split("/", 1)
|
||
host, _, port = host_port.partition(":")
|
||
connection = await asyncmy.connect(
|
||
host=host, port=int(port or 3306), user=user, password=password, db=database
|
||
)
|
||
now = dt.datetime.now(dt.UTC).replace(tzinfo=None)
|
||
try:
|
||
cursor = connection.cursor()
|
||
created = 0
|
||
for spec in ALERTS:
|
||
await cursor.execute(
|
||
"SELECT id FROM fin_risk_alert WHERE alert_no = %s", (spec["alert_no"],)
|
||
)
|
||
if await cursor.fetchone() is not None:
|
||
print(f"[跳过] {spec['alert_no']} 已存在")
|
||
continue
|
||
offset = spec["due_at_offset_minutes"]
|
||
closed_offset = spec.get("closed_at_offset_minutes")
|
||
await cursor.execute(
|
||
"INSERT INTO fin_risk_alert ("
|
||
" id, alert_no, customer_id, alert_type, alert_level, trigger_rule_codes,"
|
||
" evidence_summary, evidence_snapshot, priority_score, event_status, status,"
|
||
" ack_status, handler_id, due_at, closed_at, is_escalated, close_reason,"
|
||
" created_at, updated_at"
|
||
") VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||
(
|
||
spec["id"], spec["alert_no"], CUSTOMER_ID, spec["alert_type"],
|
||
spec["alert_level"], json.dumps(spec["trigger_rule_codes"], ensure_ascii=False),
|
||
spec["evidence_summary"],
|
||
json.dumps(spec["evidence_snapshot"], ensure_ascii=False),
|
||
spec["priority_score"], spec["event_status"], spec["status"],
|
||
spec["ack_status"], HANDLER_ID,
|
||
(now + dt.timedelta(minutes=offset)) if offset else None,
|
||
(now + dt.timedelta(minutes=closed_offset)) if closed_offset else None,
|
||
spec["is_escalated"], spec.get("close_reason"),
|
||
now, now,
|
||
),
|
||
)
|
||
created += 1
|
||
print(
|
||
f"[新增] {spec['alert_no']} 等级={spec['alert_level']} "
|
||
f"状态={spec['status']} 规则={spec['trigger_rule_codes']}"
|
||
)
|
||
await connection.commit()
|
||
await cursor.execute("SELECT COUNT(*) FROM fin_risk_alert")
|
||
total = (await cursor.fetchone())[0]
|
||
print(f"\n已提交 {created} 条;fin_risk_alert 现有 {total} 行")
|
||
return 0
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
sys.exit(asyncio.run(main()))
|