Files
group_xinghuo_jinrong/app/repository/risk_repository.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
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
2026-09-10 14:45:55 +08:00

699 lines
29 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.
"""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]