163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
"""L3 监测画像写入(B3 · PRD FR-7 / R-05 最小写入)。
|
||
|
||
合并规则(防降级,PRD FR-7):monitor_tier 取最高档(normal < watch < high),
|
||
monitor_tags 追加合并不覆盖,last_alert_id 传入时联动最新预警(未传保留旧值,
|
||
防调用方漏传抹掉),computed_at 每次写当前时间(列 NOT NULL,毫秒截断对齐
|
||
DATETIME(3),保证乐观锁读写比对一致)。
|
||
|
||
risk_score 口径(评审 P3-4 · 用户拍板 2026-09-06):一期**不写**(保持 NULL)。
|
||
该列是 R-05 动态评分模型的客户风险分,与 risk_alert.risk_score(预警单严重度)
|
||
语义不同;由 R-05 首写,避免静态分造成 tier/score 错位与二期语义污染。
|
||
注意:update 为整行覆盖,历史行非 NULL 值会被置 NULL(一期无其他写入方,无影响);
|
||
**R-05 接入时须改 merge_l3 的 risk_score 为保留 existing 值,或 update SQL 跳过该列**。
|
||
|
||
并发(B3 评审 P1-1 修复):进程内锁按 customer_id 串行减少冲突(多进程部署换
|
||
Redis SET NX,接口不变);锁超时降级与跨进程竞态由乐观锁兜底——update 比对
|
||
读时 computed_at,未命中或 insert 撞主键则重读合并重试,3 次仍冲突抛错
|
||
(风控数据宁失败不静默覆盖)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.risk import redis_gateway
|
||
from app.service.risk.locks import run_locked
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
TIER_ORDER = ("normal", "watch", "high")
|
||
L3_CACHE_KEY = "profile:l3:{customer_id}" # redis-keys 手册:String(JSON) · 5m · l3 UPDATE 时 DEL
|
||
ALERT_TYPE_TIER = {
|
||
"aml": "high",
|
||
"pattern": "watch",
|
||
"large_amount": "watch",
|
||
"freq_trade": "watch",
|
||
"suitability": "normal",
|
||
}
|
||
AML_PENDING_TAG = "aml_hit_pending_review"
|
||
_MAX_RETRIES = 3
|
||
|
||
|
||
def _ms(dt: datetime) -> datetime:
|
||
"""毫秒截断(对齐表列 DATETIME(3);否则乐观锁 expected 比对永不命中)。"""
|
||
return dt.replace(microsecond=(dt.microsecond // 1000) * 1000)
|
||
|
||
|
||
def tier_of(alert_type: str) -> str:
|
||
"""alert_type → L3 档位映射(PRD FR-7 固定映射;未知类型拒绝写入)。"""
|
||
tier = ALERT_TYPE_TIER.get(alert_type)
|
||
if tier is None:
|
||
raise ValueError(f"unknown alert_type for L3 mapping: {alert_type}")
|
||
return tier
|
||
|
||
|
||
def highest_tier(a: str, b: str) -> str:
|
||
"""取最高档(防降级核心原语)。"""
|
||
return a if TIER_ORDER.index(a) >= TIER_ORDER.index(b) else b
|
||
|
||
|
||
def merge_l3(
|
||
existing: dict[str, Any] | None,
|
||
*,
|
||
mapped_tier: str,
|
||
monitor_tags: list[str],
|
||
last_alert_id: str | None,
|
||
score_dimensions: dict[str, Any] | None = None,
|
||
computed_at: datetime | None = None,
|
||
) -> dict[str, Any]:
|
||
"""纯函数:existing 行(repo.get_l3 输出)与新事件合并后的 L3 行。
|
||
|
||
existing 为 None 表示新客户首写;risk_score 一期恒 None(不读不写,归 R-05)。
|
||
"""
|
||
if existing is None:
|
||
tier, old_tags, old_dims, old_alert = "normal", [], {}, None
|
||
else:
|
||
tier = existing.get("monitor_tier") or "normal"
|
||
old_tags = existing.get("monitor_tags") or []
|
||
old_dims = existing.get("score_dimensions") or {}
|
||
old_alert = existing.get("last_alert_id")
|
||
|
||
return {
|
||
"monitor_tier": highest_tier(tier, mapped_tier),
|
||
"risk_score": None,
|
||
"monitor_tags": sorted(set(old_tags) | set(monitor_tags)),
|
||
"score_dimensions": score_dimensions if score_dimensions is not None else old_dims,
|
||
"last_alert_id": last_alert_id if last_alert_id is not None else old_alert,
|
||
"computed_at": _ms(computed_at or datetime.now()),
|
||
}
|
||
|
||
|
||
def upsert_profile_l3(
|
||
customer_id: str,
|
||
alert_type: str,
|
||
monitor_tags: list[str] | None = None,
|
||
last_alert_id: str | None = None,
|
||
score_dimensions: dict[str, Any] | None = None,
|
||
risk_repo: RiskRepository | None = None,
|
||
computed_at: datetime | None = None,
|
||
) -> dict[str, Any]:
|
||
"""预警事件 → L3 upsert(aml 自动追加待复核标签)。
|
||
|
||
返回合并后的 L3 行(含 customer_id)。MySQL 写成功后 DEL Redis 读缓存
|
||
`profile:l3:{customer_id}`(B7 挂账②落地,PRD §5.1;DEL 失败降级 TTL 过期,
|
||
不阻塞业务)。
|
||
|
||
已知窗口(B7 自查留痕):DEL 在锁外执行,存在 cache-aside 经典竞态
|
||
(读方 miss 读旧值 → 写方 DEL → 读方回填旧值),TTL 5 分钟兜底。一期
|
||
无读路径写缓存(get_profile_l3 直读 MySQL),窗口无实际影响;**对话线
|
||
接入 Redis 热读缓存时须改延迟双删或写后比对**。
|
||
"""
|
||
repo = risk_repo or RiskRepository()
|
||
mapped_tier = tier_of(alert_type)
|
||
tags = list(monitor_tags or [])
|
||
if alert_type == "aml" and AML_PENDING_TAG not in tags:
|
||
tags.append(AML_PENDING_TAG)
|
||
|
||
def _write(locked: bool) -> dict[str, Any]: # 锁原语签名要求(是否获得锁);冲突安全由乐观锁重试保证
|
||
for attempt in range(1, _MAX_RETRIES + 1):
|
||
existing = repo.get_l3(customer_id)
|
||
merged = merge_l3(
|
||
existing,
|
||
mapped_tier=mapped_tier,
|
||
monitor_tags=tags,
|
||
last_alert_id=last_alert_id,
|
||
score_dimensions=score_dimensions,
|
||
computed_at=computed_at,
|
||
)
|
||
fields = (
|
||
merged["monitor_tier"],
|
||
merged["risk_score"],
|
||
merged["score_dimensions"],
|
||
merged["monitor_tags"],
|
||
merged["last_alert_id"],
|
||
merged["computed_at"],
|
||
)
|
||
if existing is None:
|
||
try:
|
||
repo.insert_l3(customer_id, *fields)
|
||
merged["customer_id"] = customer_id
|
||
return merged
|
||
except IntegrityError:
|
||
logger.warning("L3 insert race, retrying (attempt %d): %s", attempt, customer_id)
|
||
continue
|
||
if repo.update_l3(customer_id, *fields, expected_computed_at=existing["computed_at"]):
|
||
merged["customer_id"] = customer_id
|
||
return merged
|
||
logger.warning("L3 update lost race, retrying (attempt %d): %s", attempt, customer_id)
|
||
raise RuntimeError(f"L3 upsert conflicted after {_MAX_RETRIES} retries: {customer_id}")
|
||
|
||
merged = run_locked(f"l3:{customer_id}", _write)
|
||
redis_gateway.cache_delete(L3_CACHE_KEY.format(customer_id=customer_id))
|
||
return merged
|
||
|
||
|
||
def get_profile_l3(customer_id: str, risk_repo: RiskRepository | None = None) -> dict[str, Any] | None:
|
||
"""L3 只读薄封装(对话线/引擎复用;Redis 热读缓存归对话线接入时实现)。"""
|
||
return (risk_repo or RiskRepository()).get_l3(customer_id)
|