Files
group_xinghuo_jinrong/app/service/risk/profile_l3.py
T

163 lines
6.8 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.
"""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)