T-0 / T-0b(门禁 · 2026-09-10) - T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验 _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效) - T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权); settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色), 账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw; tests/conftest.py 四处显式 role="admin"(R-e) T-1(数据层) - scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail; core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT - 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA + 申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排) - reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail - tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁 - 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言, 含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL) T-2 / T-2b(纯函数包 + 示例实算回填) - 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32 保证 D18 跨进程同源) - 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 / FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 / 纯函数零 IO 依赖断言) - 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py, 末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁 验证 - pytest 609 passed / 3 skipped(516 → +93,零回归) - verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致 文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 · 开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
699 lines
29 KiB
Python
699 lines
29 KiB
Python
"""jinrong_agent 风控四表读写(risk_alert / risk_suitability_log / customer_profile_l3 / risk_aml_list)。
|
||
|
||
仅服务风控模块(PRD v1.0 §5.1 权限总表);不含业务判定逻辑(聚合合并/状态机入参
|
||
由 service 层决定后传入)。JSON 列以 dict 交互,内部序列化。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
from app.config.settings import settings
|
||
from app.utils.db import get_engine
|
||
|
||
EVENT_ALERT_TYPES = ("large_amount", "freq_trade", "pattern")
|
||
|
||
_AUDIT_SQL = text(
|
||
"""
|
||
INSERT INTO audit_log
|
||
(trace_id, event_type, agent_type, actor_id, customer_id, rule_id,
|
||
input_summary, decision, risk_score, handler_id, handler_result, handler_comment)
|
||
VALUES (:trace_id, :event_type, :agent_type, :actor_id, :customer_id, :rule_id,
|
||
:input_summary, :decision, :risk_score, :handler_id, :handler_result, :handler_comment)
|
||
"""
|
||
)
|
||
|
||
|
||
class RiskRepository:
|
||
"""风控产出表读写;不修改表结构(PRD 冻结约束)。"""
|
||
|
||
def __init__(self, engine: Engine | None = None) -> None:
|
||
self._engine = engine or get_engine(settings.mysql_database, "rw")
|
||
|
||
# ---------- risk_alert ----------
|
||
|
||
def find_pending_event_alert(self, customer_id: str, day_start: datetime) -> dict | None:
|
||
"""当日该客户的事件类 pending 单(聚合锚点;PRD FR-4 同日一张)——**排除代理人维度行为链单**。
|
||
|
||
PRD §4A.0 #3 / 实现方案 §2.5(评审 P0-1 收口):RISK-008 代理人行为链单
|
||
同为 `pattern` 类型,但属**代理人维度**的独立出单线,不能充当客户维度
|
||
事件单的聚合锚点——若 LIMIT 1 恰好取到行为链单,客户维度当日第二笔会被
|
||
误并进代理人单,或因误判"已有锚点可并入/锚点被占"而出重复单。
|
||
|
||
故候选取最近 50 张,再按 `payload.alert_subtype` 过滤掉含 agent_behavior 的单,
|
||
返回剩余中最新的一张(50 条为单客户单日事件单的保守上界)。
|
||
"""
|
||
placeholders = ", ".join(f":t{i}" for i in range(len(EVENT_ALERT_TYPES)))
|
||
params: dict[str, Any] = {"cid": customer_id, "day_start": day_start}
|
||
for i, t in enumerate(EVENT_ALERT_TYPES):
|
||
params[f"t{i}"] = t
|
||
sql = text(
|
||
f"""
|
||
SELECT * FROM risk_alert
|
||
WHERE customer_id = :cid
|
||
AND status = 'pending_review'
|
||
AND alert_type IN ({placeholders})
|
||
AND created_at >= :day_start
|
||
ORDER BY created_at DESC
|
||
LIMIT 50
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
rows = [self._parse_alert(dict(r)) for r in conn.execute(sql, params).mappings()]
|
||
|
||
for row in rows:
|
||
payload = row.get("payload")
|
||
if isinstance(payload, str):
|
||
payload = json.loads(payload) if payload else {}
|
||
subtypes = (payload or {}).get("alert_subtype") or []
|
||
if "agent_behavior" not in subtypes:
|
||
return row
|
||
return None
|
||
|
||
def find_pending_suitability_alert(
|
||
self, customer_id: str, product_id: str, day_start: datetime
|
||
) -> dict | None:
|
||
row = self._find_pending(customer_id, day_start, types=("suitability",))
|
||
if row and row["payload"].get("product_id") == product_id:
|
||
return row
|
||
return None
|
||
|
||
def _find_pending(
|
||
self, customer_id: str, day_start: datetime, types: tuple[str, ...]
|
||
) -> dict | None:
|
||
placeholders = ", ".join(f":t{i}" for i in range(len(types)))
|
||
params: dict[str, Any] = {"cid": customer_id, "day_start": day_start}
|
||
for i, t in enumerate(types):
|
||
params[f"t{i}"] = t
|
||
sql = text(
|
||
f"""
|
||
SELECT * FROM risk_alert
|
||
WHERE customer_id = :cid
|
||
AND status = 'pending_review'
|
||
AND alert_type IN ({placeholders})
|
||
AND created_at >= :day_start
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
row = conn.execute(sql, params).mappings().first()
|
||
return self._parse_alert(dict(row)) if row else None
|
||
|
||
def find_recent_aml_alert(self, customer_id: str, day_start: datetime) -> dict | None:
|
||
"""该客户当日最近一张 aml 单(scan 幂等检查 · B9b 核查单②/B6 评审 P3-6)。
|
||
|
||
不限 status——已处置单也算「已出」,同扫描日重扫不重复出单(模拟每日
|
||
批量口径:跨日再扫出新单)。交易触发的当日 aml 单同样命中(当日命中
|
||
已留痕,scan 无需重复出单)。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
SELECT * FROM risk_alert
|
||
WHERE customer_id = :cid AND alert_type = 'aml' AND created_at >= :day_start
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
row = conn.execute(sql, {"cid": customer_id, "day_start": day_start}).mappings().first()
|
||
return self._parse_alert(dict(row)) if row else None
|
||
|
||
def insert_alert(self, alert: dict[str, Any]) -> None:
|
||
sql = text(
|
||
"""
|
||
INSERT INTO risk_alert
|
||
(alert_id, trace_id, customer_id, trade_id, alert_type, triggered_rules,
|
||
risk_score, status, payload)
|
||
VALUES (:alert_id, :trace_id, :customer_id, :trade_id, :alert_type, :triggered_rules,
|
||
:risk_score, :status, :payload)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(sql, self._dump_alert(alert))
|
||
|
||
def append_alert_event(
|
||
self,
|
||
alert_id: str,
|
||
event: dict[str, Any],
|
||
triggered_rules: list[str],
|
||
risk_score: int,
|
||
alert_type: str,
|
||
extra_subtypes: list[str] | None = None,
|
||
) -> None:
|
||
"""读改写:追加 payload.events、合并 triggered_rules、risk_score 取 max、alert_type 更新。
|
||
|
||
extra_subtypes(C4 起):本批命中的出单子类型(如 RISK-006 的 concentration),
|
||
合并进 `payload.alert_subtype`(sorted set 并集);老单无该字段时首次追加
|
||
自动创建。**向后兼容**:不传时行为与原先完全一致。
|
||
"""
|
||
with self._engine.begin() as conn:
|
||
row = conn.execute(
|
||
text("SELECT payload, triggered_rules, risk_score FROM risk_alert WHERE alert_id = :aid"),
|
||
{"aid": alert_id},
|
||
).mappings().first()
|
||
if row is None:
|
||
raise ValueError(f"alert not found: {alert_id}")
|
||
payload = json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"]
|
||
events = payload.setdefault("events", [])
|
||
events.append(event)
|
||
if extra_subtypes:
|
||
merged_subtypes = sorted(set(payload.get("alert_subtype") or []) | set(extra_subtypes))
|
||
payload["alert_subtype"] = merged_subtypes
|
||
old_rules = (
|
||
json.loads(row["triggered_rules"])
|
||
if isinstance(row["triggered_rules"], str)
|
||
else row["triggered_rules"]
|
||
)
|
||
merged_rules = sorted(set(old_rules) | set(triggered_rules))
|
||
old_score = int(row["risk_score"] or 0)
|
||
new_score = max(old_score, int(risk_score))
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
UPDATE risk_alert
|
||
SET payload = :payload, triggered_rules = :rules,
|
||
risk_score = :score, alert_type = :atype
|
||
WHERE alert_id = :aid
|
||
"""
|
||
),
|
||
{
|
||
"payload": json.dumps(payload, ensure_ascii=False, default=str),
|
||
"rules": json.dumps(merged_rules),
|
||
"score": new_score,
|
||
"atype": alert_type,
|
||
"aid": alert_id,
|
||
},
|
||
)
|
||
|
||
def get_alert(self, alert_id: str) -> dict | None:
|
||
with self._engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT * FROM risk_alert WHERE alert_id = :aid"), {"aid": alert_id}
|
||
).mappings().first()
|
||
return self._parse_alert(dict(row)) if row else None
|
||
|
||
def find_alerts_by_trade(self, trade_id: str) -> list[dict]:
|
||
"""trade_id 已入的预警单(rebuild_alerts 幂等检查;含已处置单——重放不得绕过处置结论)。
|
||
|
||
payload 为 JSON 列(MySQL)/TEXT JSON(sqlite 测试),LIKE 匹配 trade_id 字符串;
|
||
trade_id 形如 TRD-{date}-{uuid8} 唯一性强,无误匹配面。首笔交易的 trade_id
|
||
恒写 payload.events[0].trade_id,LIKE 覆盖事件类/suitability/aml 全部出单路径。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
SELECT alert_id, alert_type, status
|
||
FROM risk_alert
|
||
WHERE payload LIKE :pat
|
||
ORDER BY created_at
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
return [
|
||
dict(r)
|
||
for r in conn.execute(sql, {"pat": f"%{trade_id}%"}).mappings()
|
||
]
|
||
|
||
def has_engine_error_audit(self, trade_id: str) -> bool:
|
||
"""存在 decision='risk_engine_error' 的该笔审计(rebuild_alerts 警示用)。
|
||
|
||
audit_log 无 trade_id 列,trade_id 在 input_summary(JSON 列/TEXT JSON)
|
||
内,同 find_alerts_by_trade 的 LIKE 口径。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
SELECT 1 FROM audit_log
|
||
WHERE decision = 'risk_engine_error' AND input_summary LIKE :pat
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
return conn.execute(sql, {"pat": f"%{trade_id}%"}).first() is not None
|
||
|
||
def list_alerts(
|
||
self,
|
||
status: str | None = None,
|
||
alert_type: str | None = None,
|
||
customer_id: str | None = None,
|
||
start: datetime | None = None,
|
||
end: datetime | None = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> tuple[list[dict], int]:
|
||
"""预警台账分页查询(PRD FR-4);过滤参数全部可选。"""
|
||
where = ["1=1"]
|
||
params: dict[str, Any] = {}
|
||
if status:
|
||
where.append("status = :status")
|
||
params["status"] = status
|
||
if alert_type:
|
||
where.append("alert_type = :atype")
|
||
params["atype"] = alert_type
|
||
if customer_id:
|
||
where.append("customer_id = :cid")
|
||
params["cid"] = customer_id
|
||
if start:
|
||
where.append("created_at >= :start")
|
||
params["start"] = start
|
||
if end:
|
||
where.append("created_at < :end")
|
||
params["end"] = end
|
||
where_sql = " AND ".join(where)
|
||
with self._engine.connect() as conn:
|
||
total = conn.execute(
|
||
text(f"SELECT COUNT(*) FROM risk_alert WHERE {where_sql}"), params
|
||
).scalar_one()
|
||
rows = conn.execute(
|
||
text(
|
||
f"""
|
||
SELECT * FROM risk_alert WHERE {where_sql}
|
||
ORDER BY created_at DESC
|
||
LIMIT :lim OFFSET :off
|
||
"""
|
||
),
|
||
{**params, "lim": page_size, "off": (page - 1) * page_size},
|
||
).mappings()
|
||
return [self._parse_alert(dict(r)) for r in rows], int(total)
|
||
|
||
def update_alert_status(
|
||
self, alert_id: str, handler_result: str, handler_id: str, handler_comment: str | None
|
||
) -> bool:
|
||
"""状态机:仅 pending_review 可处置(PRD FR-4);返回是否发生变更。"""
|
||
sql = text(
|
||
"""
|
||
UPDATE risk_alert
|
||
SET status = :result, handler_id = :hid, handler_result = :result,
|
||
handler_comment = :comment, handled_at = :handled_at
|
||
WHERE alert_id = :aid AND status = 'pending_review'
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
res = conn.execute(
|
||
sql,
|
||
{
|
||
"result": handler_result,
|
||
"hid": handler_id,
|
||
"comment": handler_comment,
|
||
"handled_at": datetime.now(),
|
||
"aid": alert_id,
|
||
},
|
||
)
|
||
return res.rowcount == 1
|
||
|
||
def handle_alert_with_audit(
|
||
self,
|
||
alert_id: str,
|
||
handler_result: str,
|
||
handler_id: str,
|
||
handler_comment: str | None,
|
||
audit_entry: dict[str, Any],
|
||
) -> bool:
|
||
"""状态机变更 + 处置审计**同事务**(B7 挂账⑦)。
|
||
|
||
UPDATE ... WHERE status='pending_review'(防并发抢先)命中后才同事务
|
||
INSERT audit_log——审计失败整体回滚,不产生无痕状态变更(P-05);返回
|
||
False 表示已被并发处置,service 层转 StateConflict。
|
||
"""
|
||
update_sql = text(
|
||
"""
|
||
UPDATE risk_alert
|
||
SET status = :result, handler_id = :hid, handler_result = :result,
|
||
handler_comment = :comment, handled_at = :handled_at
|
||
WHERE alert_id = :aid AND status = 'pending_review'
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
res = conn.execute(
|
||
update_sql,
|
||
{
|
||
"result": handler_result,
|
||
"hid": handler_id,
|
||
"comment": handler_comment,
|
||
"handled_at": datetime.now(),
|
||
"aid": alert_id,
|
||
},
|
||
)
|
||
if res.rowcount != 1:
|
||
return False
|
||
conn.execute(_AUDIT_SQL, self._dump_audit(audit_entry))
|
||
return True
|
||
|
||
def list_pending_alerts_all(self, page_size: int = 1000) -> list[dict]:
|
||
"""全部 pending_review 单(C5 时效升级扫描输入;演示规模一次取回,Python 端判级)。"""
|
||
with self._engine.connect() as conn:
|
||
rows = conn.execute(
|
||
text(
|
||
"""
|
||
SELECT * FROM risk_alert
|
||
WHERE status = 'pending_review'
|
||
ORDER BY created_at ASC
|
||
LIMIT :lim
|
||
"""
|
||
),
|
||
{"lim": page_size},
|
||
).mappings()
|
||
return [self._parse_alert(dict(r)) for r in rows]
|
||
|
||
def update_alert_escalation(
|
||
self, alert_id: str, escalation_level: int, escalated_at: datetime, trace_id: str
|
||
) -> bool:
|
||
"""payload 升级标记独占写入(RISK-007;status/handler_* 列一律不碰)。
|
||
|
||
统一读改写(评审 P1-1 定案):SELECT payload → 合并 escalation_level/
|
||
escalated_at/escalation_trace_id → UPDATE payload。与 append_alert_event
|
||
同模式(跨 MySQL/sqlite 已验证),不引入 JSON_SET 方言分支;并发窗口由
|
||
「单发定时脚本 + 幂等闸门(仅升不降)」兜底。WHERE alert_id=:aid,返回
|
||
rowcount==1。
|
||
"""
|
||
with self._engine.begin() as conn:
|
||
row = conn.execute(
|
||
text("SELECT payload FROM risk_alert WHERE alert_id = :aid"),
|
||
{"aid": alert_id},
|
||
).mappings().first()
|
||
if row is None:
|
||
return False
|
||
payload = json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"]
|
||
payload["escalation_level"] = escalation_level
|
||
payload["escalated_at"] = escalated_at.isoformat()
|
||
payload["escalation_trace_id"] = trace_id
|
||
res = conn.execute(
|
||
text("UPDATE risk_alert SET payload = :payload WHERE alert_id = :aid"),
|
||
{
|
||
"payload": json.dumps(payload, ensure_ascii=False, default=str),
|
||
"aid": alert_id,
|
||
},
|
||
)
|
||
return res.rowcount == 1
|
||
|
||
# ---------- 代理人行为链(C6 · RISK-008 数据源) ----------
|
||
|
||
def list_audit_events(
|
||
self,
|
||
event_type: str,
|
||
since: datetime,
|
||
actor_id: str | None = None,
|
||
decision: str | None = None,
|
||
limit: int = 2000,
|
||
) -> list[dict[str, Any]]:
|
||
"""audit_log 滑窗查询(RISK-008 数据源;input_summary 解析回 dict)。
|
||
|
||
WHERE event_type=:et AND created_at>=:since [AND actor_id=:aid] [AND decision=:d]
|
||
ORDER BY created_at ASC。条件 B/C 用 actor_id+decision='forbidden';
|
||
条件 A 用 event_type='trade_request' 全量(actor 过滤在 Python 端做,排除 SYSTEM)。
|
||
"""
|
||
where = ["event_type = :et", "created_at >= :since"]
|
||
params: dict[str, Any] = {"et": event_type, "since": since}
|
||
if actor_id:
|
||
where.append("actor_id = :aid")
|
||
params["aid"] = actor_id
|
||
if decision:
|
||
where.append("decision = :dec")
|
||
params["dec"] = decision
|
||
params["lim"] = limit
|
||
sql = text(
|
||
f"SELECT * FROM audit_log WHERE {' AND '.join(where)} ORDER BY created_at ASC LIMIT :lim"
|
||
)
|
||
with self._engine.connect() as conn:
|
||
rows = [dict(r) for r in conn.execute(sql, params).mappings()]
|
||
for row in rows:
|
||
if isinstance(row.get("input_summary"), str):
|
||
try:
|
||
row["input_summary"] = json.loads(row["input_summary"])
|
||
except (ValueError, TypeError):
|
||
row["input_summary"] = {}
|
||
if isinstance(row.get("created_at"), str):
|
||
try:
|
||
row["created_at"] = datetime.fromisoformat(row["created_at"])
|
||
except ValueError:
|
||
pass
|
||
return rows
|
||
|
||
def find_agent_behavior_alert(self, actor_id: str, day_start: datetime) -> dict | None:
|
||
"""同代理人当日 pending 行为链单(去重锚点)。
|
||
|
||
SQL 取最近 pending pattern 单,Python 过滤 payload.alert_subtype 含
|
||
agent_behavior 且 payload.actor_id==actor_id(不用 payload LIKE:JSON
|
||
键值歧义 + 序列化空格差异两坑,挂账 #9 登记性能,一期 Python 过滤最稳)。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
SELECT * FROM risk_alert
|
||
WHERE status = 'pending_review' AND alert_type = 'pattern'
|
||
AND created_at >= :day_start
|
||
ORDER BY created_at DESC
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
rows = [self._parse_alert(dict(r)) for r in conn.execute(sql, {"day_start": day_start}).mappings()]
|
||
for row in rows:
|
||
payload = row.get("payload") or {}
|
||
sub = payload.get("alert_subtype")
|
||
if isinstance(sub, str):
|
||
sub = [sub]
|
||
if "agent_behavior" in (sub or []) and payload.get("actor_id") == actor_id:
|
||
return row
|
||
return None
|
||
|
||
def merge_agent_behavior_payload(
|
||
self,
|
||
alert_id: str,
|
||
*,
|
||
subtypes_hit: list[str],
|
||
customers: list[str],
|
||
evidence_trace_ids: list[str],
|
||
timeline: dict[str, Any],
|
||
) -> bool:
|
||
"""agent_behavior 单追加:subtypes_hit/customers/evidence_trace_ids/timeline 并集(读改写)。
|
||
|
||
与 append_alert_event 互补——后者只合并 payload.events + alert_subtype;
|
||
本方法合并行为链专属的标量列表字段,保证"同日多子条件只一张单"语义(A-12 断言)。
|
||
"""
|
||
with self._engine.begin() as conn:
|
||
row = conn.execute(
|
||
text("SELECT payload FROM risk_alert WHERE alert_id = :aid"),
|
||
{"aid": alert_id},
|
||
).mappings().first()
|
||
if row is None:
|
||
return False
|
||
payload = json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"]
|
||
payload["subtypes_hit"] = sorted(set(payload.get("subtypes_hit") or []) | set(subtypes_hit))
|
||
payload["customers"] = sorted(set(payload.get("customers") or []) | set(customers))
|
||
payload["evidence_trace_ids"] = sorted(
|
||
set(payload.get("evidence_trace_ids") or []) | set(evidence_trace_ids)
|
||
)
|
||
old_timeline = payload.get("timeline") or {}
|
||
for k, v in (timeline or {}).items():
|
||
old_timeline.setdefault(k, []).extend(v or [])
|
||
payload["timeline"] = old_timeline
|
||
conn.execute(
|
||
text("UPDATE risk_alert SET payload = :payload WHERE alert_id = :aid"),
|
||
{
|
||
"payload": json.dumps(payload, ensure_ascii=False, default=str),
|
||
"aid": alert_id,
|
||
},
|
||
)
|
||
return True
|
||
|
||
@staticmethod
|
||
def _dump_alert(alert: dict[str, Any]) -> dict[str, Any]:
|
||
out = dict(alert)
|
||
out["triggered_rules"] = json.dumps(alert["triggered_rules"])
|
||
out["payload"] = json.dumps(alert["payload"], ensure_ascii=False, default=str)
|
||
return out
|
||
|
||
@staticmethod
|
||
def _parse_alert(row: dict[str, Any]) -> dict[str, Any]:
|
||
row["triggered_rules"] = json.loads(row["triggered_rules"])
|
||
row["payload"] = json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"]
|
||
# sqlite 原生 DDL 无类型信息,created_at/handled_at 读回为字符串;MySQL 端
|
||
# DATETIME 已是 datetime。统一在解析层转回,避免调用方按 datetime 计算超期/判级。
|
||
if isinstance(row.get("created_at"), str):
|
||
try:
|
||
row["created_at"] = datetime.fromisoformat(row["created_at"])
|
||
except ValueError:
|
||
pass
|
||
if isinstance(row.get("handled_at"), str):
|
||
try:
|
||
row["handled_at"] = datetime.fromisoformat(row["handled_at"])
|
||
except ValueError:
|
||
pass
|
||
return row
|
||
|
||
# ---------- audit_log(风控判定审计 · 只 INSERT,PRD §7.3)----------
|
||
|
||
def insert_audit_log(self, entry: dict[str, Any]) -> None:
|
||
with self._engine.begin() as conn:
|
||
conn.execute(_AUDIT_SQL, self._dump_audit(entry))
|
||
|
||
# ---------- input_guard_log(输入安全防护 · 只 INSERT,手册 P-05 双写)----------
|
||
|
||
def insert_input_guard_log(
|
||
self,
|
||
trace_id: str,
|
||
agent_type: str,
|
||
actor_id: str,
|
||
guard_type: str,
|
||
action: str,
|
||
raw_excerpt: str | None = None,
|
||
session_id: str | None = None,
|
||
) -> None:
|
||
"""安全防护留痕(T-02 挂账⑧收口);ENUM 口径见 01-mysql-共用底座.sql。"""
|
||
sql = text(
|
||
"""
|
||
INSERT INTO input_guard_log
|
||
(trace_id, session_id, agent_type, actor_id, guard_type, raw_excerpt, action)
|
||
VALUES (:trace_id, :session_id, :agent_type, :actor_id, :guard_type, :raw_excerpt, :action)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"trace_id": trace_id,
|
||
"session_id": session_id,
|
||
"agent_type": agent_type,
|
||
"actor_id": actor_id,
|
||
"guard_type": guard_type,
|
||
"raw_excerpt": (raw_excerpt or "")[:1024],
|
||
"action": action,
|
||
},
|
||
)
|
||
|
||
@staticmethod
|
||
def _dump_audit(entry: dict[str, Any]) -> dict[str, Any]:
|
||
params = dict(entry)
|
||
params["input_summary"] = json.dumps(
|
||
entry.get("input_summary") or {}, ensure_ascii=False, default=str
|
||
)
|
||
return params
|
||
|
||
# ---------- risk_suitability_log ----------
|
||
|
||
def insert_suitability_log(self, log: dict[str, Any]) -> None:
|
||
"""21 列全列 INSERT(AL-02/05 对齐 main 契约,字段来源 build_suitability_log_row)。
|
||
|
||
rule_refs 以 JSON 字符串落库(MySQL JSON 列 / sqlite TEXT 列同型兼容)。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
INSERT INTO risk_suitability_log
|
||
(trace_id, customer_id, product_id, product_name,
|
||
customer_risk_level, product_risk_level, investor_category,
|
||
match_result, mismatch_type, is_matched, is_blocked,
|
||
requires_disclosure, needs_branch_confirm, risk_was_expired,
|
||
block_reason, block_response_code, check_source, actor_id,
|
||
request_ref, profile_l1_version, rule_refs)
|
||
VALUES (:trace_id, :customer_id, :product_id, :product_name,
|
||
:customer_risk_level, :product_risk_level, :investor_category,
|
||
:match_result, :mismatch_type, :is_matched, :is_blocked,
|
||
:requires_disclosure, :needs_branch_confirm, :risk_was_expired,
|
||
:block_reason, :block_response_code, :check_source, :actor_id,
|
||
:request_ref, :profile_l1_version, :rule_refs)
|
||
"""
|
||
)
|
||
params = dict(log)
|
||
if isinstance(params.get("rule_refs"), (list, tuple)):
|
||
params["rule_refs"] = json.dumps(params["rule_refs"], ensure_ascii=False)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(sql, params)
|
||
|
||
# ---------- customer_profile_l3 ----------
|
||
|
||
def get_l3(self, customer_id: str) -> dict | None:
|
||
with self._engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT * FROM customer_profile_l3 WHERE customer_id = :cid"), {"cid": customer_id}
|
||
).mappings().first()
|
||
if not row:
|
||
return None
|
||
data = dict(row)
|
||
for key in ("score_dimensions", "monitor_tags"):
|
||
if isinstance(data.get(key), str):
|
||
data[key] = json.loads(data[key])
|
||
return data
|
||
|
||
def insert_l3(
|
||
self,
|
||
customer_id: str,
|
||
monitor_tier: str,
|
||
risk_score: int | None,
|
||
score_dimensions: dict | list | None,
|
||
monitor_tags: list[str],
|
||
last_alert_id: str | None,
|
||
computed_at: datetime,
|
||
) -> None:
|
||
"""新行插入;行已存在时由 service 层读后改走 update_l3(合并逻辑在 service)。"""
|
||
sql = text(
|
||
"""
|
||
INSERT INTO customer_profile_l3
|
||
(customer_id, monitor_tier, risk_score, score_dimensions, monitor_tags,
|
||
last_alert_id, computed_at)
|
||
VALUES (:cid, :tier, :score, :dims, :tags, :last_alert, :computed_at)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"cid": customer_id,
|
||
"tier": monitor_tier,
|
||
"score": risk_score,
|
||
"dims": json.dumps(score_dimensions or {}, ensure_ascii=False),
|
||
"tags": json.dumps(monitor_tags, ensure_ascii=False),
|
||
"last_alert": last_alert_id,
|
||
"computed_at": computed_at,
|
||
},
|
||
)
|
||
|
||
def update_l3(
|
||
self,
|
||
customer_id: str,
|
||
monitor_tier: str,
|
||
risk_score: int | None,
|
||
score_dimensions: dict | list | None,
|
||
monitor_tags: list[str],
|
||
last_alert_id: str | None,
|
||
computed_at: datetime,
|
||
expected_computed_at: datetime | None = None,
|
||
) -> bool:
|
||
"""整行更新(service 层完成最高档/tags 合并后调用;computed_at NOT NULL 必传)。
|
||
|
||
expected_computed_at 传入时为乐观锁(比对读时的 computed_at),行已被并发
|
||
修改则不命中,返回 False 由 service 层重读重试(B3 评审 P1-1 防丢更新)。
|
||
"""
|
||
sql = """
|
||
UPDATE customer_profile_l3
|
||
SET monitor_tier = :tier, risk_score = :score, score_dimensions = :dims,
|
||
monitor_tags = :tags, last_alert_id = :last_alert, computed_at = :computed_at
|
||
WHERE customer_id = :cid"""
|
||
params: dict[str, Any] = {
|
||
"cid": customer_id,
|
||
"tier": monitor_tier,
|
||
"score": risk_score,
|
||
"dims": json.dumps(score_dimensions or {}, ensure_ascii=False),
|
||
"tags": json.dumps(monitor_tags, ensure_ascii=False),
|
||
"last_alert": last_alert_id,
|
||
"computed_at": computed_at,
|
||
}
|
||
if expected_computed_at is not None:
|
||
sql += " AND computed_at = :expected"
|
||
params["expected"] = expected_computed_at
|
||
with self._engine.begin() as conn:
|
||
res = conn.execute(text(sql), params)
|
||
return res.rowcount == 1
|
||
|
||
# ---------- risk_aml_list ----------
|
||
|
||
def list_active_aml_entries(self) -> list[dict]:
|
||
with self._engine.connect() as conn:
|
||
rows = conn.execute(
|
||
text("SELECT * FROM risk_aml_list WHERE is_active = 1")
|
||
).mappings()
|
||
return [dict(r) for r in rows]
|