"""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]