130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
"""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,
|
||
}
|