Files
group_fqcd_jr/tools/seed_risk_alert_demo_data.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

192 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""造风控演示数据:让概览 / 查询 / 证据三个只读工具都能返回真实内容。
**为什么需要**:配置与 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()))