Files
XingHuo/app/service/risk/aml_service.py
T

130 lines
5.2 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.
"""AML 名单匹配(B4 · PRD FR-5 / AML-001~002)。
一期降级口径:仅 display_name 归一化(去空白 + 大小写折叠)+ difflib 相似度;
证件/银行卡匹配待 Core 提供证件数据后启用(表 id_no/bank_card_no 已预留)。
阈值:名单行 match_threshold 优先(表默认 0.85),仅缺 NULL 时回落
settings.risk_aml_default_threshold(is None 判断,显式 0 不误回落——评审 P3-7)。
命中动作(独立 aml 预警单 + L3 high 标记)由调用方编排:engine(交易触发)/
scan_all(手动全量扫描);不冻结、不自动上报(附表 §2 行为边界)。
脱敏留痕(评审 P3-9):matched_name 进 payload/审计依赖种子「脱敏展示名口径」
(名单表 full_name 注释);接入真实名单数据时须在出口接 utils/desensitize,
挂账见开发计划 B4 行。
"""
from __future__ import annotations
import difflib
import logging
from datetime import date, datetime, time
from typing import Any
from app.config.settings import settings
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.risk.alert_service import record_aml_alert
from app.service.risk.profile_l3 import upsert_profile_l3
from app.utils.trace import ensure_trace
logger = logging.getLogger(__name__)
def normalize_name(name: str) -> str:
"""去全部空白 + 大小写折叠(PRD FR-5 一期归一化口径)。"""
return "".join(name.split()).casefold()
def similarity(a: str, b: str) -> float:
"""归一化后的相似度(difflib SequenceMatcher ratio ∈ [0, 1])。"""
return difflib.SequenceMatcher(None, a, b).ratio()
def match_name(
customer_name: str, entries: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""客户姓名 ↔ 名单条目匹配,返回命中明细(进预警 payload / 审计)。
entries 为 repo.list_active_aml_entries() 输出;ratio ≥ 行阈值即命中。
"""
norm = normalize_name(customer_name)
hits: list[dict[str, Any]] = []
for e in entries:
raw = e.get("match_threshold")
threshold = float(raw) if raw is not None else float(settings.risk_aml_default_threshold)
ratio = similarity(norm, normalize_name(e["full_name"]))
if ratio >= threshold:
hits.append(
{
"list_id": e["list_id"],
"list_type": e["list_type"],
"matched_name": e["full_name"],
"similarity": round(ratio, 4),
"threshold": threshold,
"list_version": e["list_version"],
"source": e.get("source"),
}
)
return hits
def match_customer(
customer_id: str,
core_ro: CoreReadOnlyRepository | None = None,
risk_repo: RiskRepository | None = None,
) -> list[dict[str, Any]]:
"""按客户匹配活跃名单(客户不存在返回空,不抛错)。"""
core = core_ro or CoreReadOnlyRepository()
repo = risk_repo or RiskRepository()
l0 = core.get_customer_l0(customer_id)
if not l0:
return []
return match_name(l0["display_name"], repo.list_active_aml_entries())
def scan_all(
core_ro: CoreReadOnlyRepository | None = None,
risk_repo: RiskRepository | None = None,
) -> dict[str, Any]:
"""手动全量扫描(PRD FR-5 触发时机 2,B6 接 POST /api/risk/aml/scan)。
每个命中客户:一张 aml 独立预警单(matches 全量进 payload)+ L3 high 标记。
幂等防护(B9b 核查单②/B6 评审 P3-6):同客户当日已有 aml 单(含交易触发
与已处置单)不再重复出单,计入 skipped_existing——模拟每日批量口径,重复
点击不刷单;读后写竞态在手动单发操作下可接受(无并发出单场景)。
"""
core = core_ro or CoreReadOnlyRepository()
repo = risk_repo or RiskRepository()
ensure_trace() # 手动扫描入口兜底归因(B6 API 场景保留中间件 trace,评审 P3-8)
entries = repo.list_active_aml_entries()
customers = core.list_active_customers()
day_start = datetime.combine(date.today(), time.min)
alerts: list[str] = []
skipped_existing: list[str] = []
hit_customers = 0
for c in customers:
hits = match_name(c["display_name"], entries)
if not hits:
continue
hit_customers += 1
existing = repo.find_recent_aml_alert(c["customer_id"], day_start)
if existing:
skipped_existing.append(existing["alert_id"])
continue
alert = record_aml_alert(
c["customer_id"], {"trigger": "scan", "matches": hits}, risk_repo=repo
)
upsert_profile_l3(
c["customer_id"], "aml", last_alert_id=alert["alert_id"], risk_repo=repo
)
alerts.append(alert["alert_id"])
logger.info(
"AML scan_all: scanned=%d hit_customers=%d new_alerts=%d skipped_existing=%d",
len(customers), hit_customers, len(alerts), len(skipped_existing),
)
return {
"scanned": len(customers),
"hit_customers": hit_customers,
"alerts": alerts,
"skipped_existing": skipped_existing,
}