"""风控预警只读研判草案。 本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、 放行、结案或升级等人工处置动作。 """ from __future__ import annotations import re from datetime import UTC, date, datetime from decimal import Decimal, InvalidOperation from typing import Any from app.core.timeutil import local_date, local_hour VERDICT_RELEASE = "可考虑放行" # 定投类工单渠道。扫描侧按"有效定投工单"生成 RW-018 预警时接受这两个值 # (`risk_scan_service.py:294` 的 `work_order.channel in {"定投", "自动定投"}`)。 # 研判侧必须用**同一份口径**:原先详情级只认 `"定投"`,于是"自动定投"的预警 # 会出现"扫描按有效工单生成、详情却说未确认有效定投工单"的自相矛盾。 DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"}) VERDICT_SUSPECTED_FALSE_POSITIVE = "疑似误报" VERDICT_CONTINUE_REVIEW = "继续复核" VERDICT_RISK_SUPPORTED = "证据支持风险" _VERDICT_SEVERITY = { VERDICT_RISK_SUPPORTED: 4, VERDICT_CONTINUE_REVIEW: 3, VERDICT_SUSPECTED_FALSE_POSITIVE: 2, VERDICT_RELEASE: 1, } _CONFIDENCE_SEVERITY = {"低": 1, "中": 2, "高": 3} def assess_alert_list_item(item: dict[str, Any]) -> dict[str, Any]: """根据预警列表字段给出初步复核方向。""" rules = _rule_codes(item) assessments = [_assess_list_rule(rule, item) for rule in rules] return _combine_assessments(item, rules, assessments) def assess_alert_detail(detail: dict[str, Any]) -> dict[str, Any]: """根据完整证据详情给出规则级只读研判草案。""" alert = _mapping(detail.get("alert")) rules = _rule_codes(alert) assessments = [_assess_detail_rule(rule, detail) for rule in rules] return _combine_assessments(alert, rules, assessments) def _assess_list_rule(rule: str, item: dict[str, Any]) -> dict[str, Any]: item = _flatten_merged_evidence(item) if rule == "RW-018": # 原先这里**完全不看 item**,无条件返回"可考虑放行",而且理由文本是硬编码的 # "现有摘要显示交易来自有效定投工单"。于是列表里那些渠道并不匹配、或证据里 # 根本没有工单信息的 RW-018 预警,也被标成"可考虑放行"——等于把待核实的预警 # 在列表层提前放掉了(详情层另有判断,但列表是风控专员最先看到的一屏)。 # # 现在按 snapshot 里的渠道判断,并与扫描侧(`risk_scan_service.py:294`) # 共用同一份渠道口径 —— 两处不一致会造成"扫描认为有效、详情认为未确认"的自相矛盾。 snapshot = _mapping(item.get("evidence_snapshot")) channel = snapshot.get("channel") if isinstance(channel, str) and channel in DIRECT_INVESTMENT_CHANNELS: return _assessment( VERDICT_RELEASE, "高", [f"命中低优先级频繁交易初筛,关联工单渠道为 {channel},属有效定投场景。"], ["核验定投工单状态、交易周期和客户授权记录。"], ) return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "中", ["命中频繁交易初筛,但证据中未确认有效定投工单。"], ["核验定投工单状态与客户授权记录后,由风控专员判断是否放行。"], ) if rule == "RW-015": return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "中", ["命中低风险非正常时段小额操作,初步更偏向运营特征而非高风险欺诈。"], ["核验登录设备、交易地点和客户当日操作意图。"], ) if rule == "RW-007": return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["适当性错配需要核对客户风险等级、产品风险等级和交易留痕完整性。"], ["读取预警详情,核验风险等级差和双录、确认、留痕字段。"], ) if rule in {"RW-003", "RW-012"}: return _assessment( VERDICT_CONTINUE_REVIEW, "中", [f"规则 {rule} 属于大额资金或老年客户高风险场景,列表不足以判断误报。"], ["读取预警详情并核对资金流、历史均值和登录设备证据。"], ) return _assessment( VERDICT_CONTINUE_REVIEW, "低", ["当前规则没有内置误报豁免判断。"], ["读取完整证据后由风控专员人工复核。"], ) def _flatten_merged_evidence(detail: dict[str, Any]) -> dict[str, Any]: """把"合并预警"的嵌套证据摊回顶层,再交给各条规则的研判函数。 同一笔交易命中多条规则时,扫描器会把它们合并成一条 (`risk_scan_service._merge_same_transaction_alerts`)。合并后的 `evidence_snapshot` 只保留 `product_id` 和 `merged_alerts`,各条**原有的证据键被塞进 `merged_alerts[].evidence`**;而各研判函数读的是**顶层键**(`snapshot.get("ratio")` 之类),于是合并过的预警一律读不到证据、降级成"缺证据无法复核" —— 合并本来是为了少几条噪音,结果把这些预警的研判全废掉了(docs/25 P2)。 在入口统一摊平:顶层已有的键优先(它来自 priority_score 最高的那条主预警), 再按顺序补入各子条目的证据键。所有规则共用这一步,不必各自去认嵌套结构。 """ snapshot = _mapping(detail.get("evidence_snapshot")) merged = snapshot.get("merged_alerts") if not isinstance(merged, list): return detail flat = dict(snapshot) for entry in merged: for key, value in _mapping(_mapping(entry).get("evidence")).items(): flat.setdefault(key, value) return {**detail, "evidence_snapshot": flat} def _assess_detail_rule(rule: str, detail: dict[str, Any]) -> dict[str, Any]: detail = _flatten_merged_evidence(detail) if rule == "RW-003": return _assess_rw003(detail) if rule == "RW-007": return _assess_rw007(detail) if rule == "RW-012": return _assess_rw012(detail) if rule == "RW-015": return _assess_rw015(detail) if rule == "RW-018": return _assess_rw018(detail) return _assessment( VERDICT_CONTINUE_REVIEW, "低", [f"规则 {rule} 尚未配置只读误报研判依据。"], ["由风控专员根据完整证据人工判断。"], ) def _assess_rw003(detail: dict[str, Any]) -> dict[str, Any]: transaction = _mapping(detail.get("transaction")) snapshot = _mapping(detail.get("evidence_snapshot")) amount = _decimal(transaction.get("amount")) ratio = _decimal(snapshot.get("ratio")) if amount is None or ratio is None: return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["缺少赎回金额或赎回比例,无法复核大额快进快出条件。"], ["补查对应入金流水和赎回交易金额。"], ) if amount >= Decimal("500000") and ratio >= Decimal("0.8"): return _assessment( VERDICT_RISK_SUPPORTED, "高", [ f"赎回金额 {amount:.2f} 元已达到 500000 元阈值。", f"赎回比例 {ratio:.2%} 已达到 80% 阈值。", ], ["继续核实资金来源、交易目的和客户风险承受能力。"], ) return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", [ f"当前赎回金额 {amount:.2f} 元或赎回比例 {ratio:.2%} 已不满足规则阈值。", ], ["核对交易是否发生冲正、撤销或证据快照是否过期。"], ) def _assess_rw007(detail: dict[str, Any]) -> dict[str, Any]: customer = _mapping(detail.get("customer")) product = _mapping(detail.get("product")) work_order = _mapping(detail.get("work_order")) investor_value = _risk_level_number(customer.get("investor_type"), "C") product_value = _risk_level_number(product.get("risk_level"), "R") if investor_value is None or product_value is None: return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["缺少客户风险等级或产品风险等级,无法复核适当性错配。"], ["补查客户最新风险测评和产品风险等级。"], ) level_gap = product_value - investor_value if level_gap <= 0: return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", [ f"当前客户等级 {customer.get('investor_type')} 与产品等级 " f"{product.get('risk_level')} 已不存在风险等级差。", ], ["核对预警生成时和当前测评记录是否发生变更。"], ) missing_traces = _missing_traces(product, work_order) if not missing_traces: return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", [ f"当前存在 {level_gap} 级风险等级差,但产品要求的交易留痕均已具备。", ], ["复核留痕时间、录音编号和二次确认记录的真实有效性。"], ) return _assessment( VERDICT_RISK_SUPPORTED, "高", [ f"客户等级与产品等级相差 {level_gap} 级。", f"缺少交易留痕:{'、'.join(missing_traces)}。", ], ["继续核实双录、风险揭示确认和二次确认材料。"], ) def _assess_rw012(detail: dict[str, Any]) -> dict[str, Any]: customer = _mapping(detail.get("customer")) transaction = _mapping(detail.get("transaction")) age = _age(customer.get("birth_date")) amount = _decimal(transaction.get("amount")) if age is None or amount is None: return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["缺少客户年龄或赎回金额,无法复核老年客户异常赎回。"], ["补查客户出生日期和赎回交易金额。"], ) if age < 65 or amount < Decimal("300000"): return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", [ f"当前客户年龄 {age} 岁或赎回金额 {amount:.2f} 元已不满足规则门槛。", ], ["核对证据快照是否来自已变更或已冲正的交易。"], ) # 复核生成条件里的"≥ 3 倍历史均值"。原先这里没有这一项,快照里也没有均值, # 于是一笔只达到 2 倍均值的赎回会被判成"证据支持风险" —— 而它的建议文本写着 # "继续核实一年期历史交易均值",说明作者知道该看,只是当时确实没有数据可看。 # 现在扫描侧把 average_amount / ratio 写进了快照,这里就能真正核。 snapshot = _mapping(detail.get("evidence_snapshot")) ratio = _decimal(snapshot.get("ratio")) if ratio is None: return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["证据快照中没有历史交易均值,无法复核是否达到规则的 3 倍门槛。"], ["补查该客户近一年历史交易均值;数据缺失时由风控专员人工判断。"], ) if ratio < Decimal("3"): return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", [f"赎回金额为历史均值的 {ratio:.2f} 倍,未达到规则的 3 倍门槛。"], ["核对历史均值口径与统计窗口;确认后按误报关闭。"], ) confirmed_at = _datetime(transaction.get("confirmed_at")) logins = detail.get("login_records") latest_login = _latest_successful_login(logins, confirmed_at) if latest_login is None: return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["客户年龄和赎回金额达到门槛,但缺少交易日前的成功登录记录。"], ["补查交易前登录设备、IP 地区和设备常用性。"], ) if not bool(latest_login.get("is_common_device")): return _assessment( VERDICT_RISK_SUPPORTED, "高", [ f"{age} 岁客户赎回 {amount:.2f} 元。", "交易前最近一次成功登录使用非常用设备。", ], ["继续核实一年期历史交易均值、客户本人意愿和设备归属。"], ) return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "中", ["当前交易前最近一次成功登录使用常用设备。"], ["继续核验历史交易均值和客户赎回意图。"], ) def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]: transaction = _mapping(detail.get("transaction")) amount = _decimal(transaction.get("amount")) confirmed_at = _datetime(transaction.get("confirmed_at")) if amount is None or confirmed_at is None: return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["缺少交易金额或成交时间。"], ["补查交易明细和成交时间。"], ) # 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让 [0,6) UTC 变成 # 北京 08:00–14:00(docs/25 P1 #6)。展示的小时数同样要换算,否则风控专员看到的 # 是 UTC 时刻、与他的直觉差 8 小时,无法核对。 if amount <= Decimal("10000") and 0 <= local_hour(confirmed_at) < 6: return _assessment( VERDICT_RELEASE, "中", [ f"交易金额 {amount:.2f} 元较小,并发生在 {local_hour(confirmed_at)} 时。", "该规则本身属于低优先级运营特征初筛。", ], ["核验设备、交易地点和客户操作意图后人工决定是否放行。"], ) return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "中", ["当前金额或成交时段已不满足非正常时段小额操作条件。"], ["核对证据快照和当前交易记录是否一致。"], ) def _assess_rw018(detail: dict[str, Any]) -> dict[str, Any]: work_order = _mapping(detail.get("work_order")) channel = work_order.get("channel") if channel in DIRECT_INVESTMENT_CHANNELS: return _assessment( VERDICT_RELEASE, "高", ["频繁交易初筛关联有效定投工单,现有证据支持正常定投场景。"], ["人工核验工单状态、签约周期、扣款授权和交易频率后考虑放行。"], ) return _assessment( VERDICT_CONTINUE_REVIEW, "中", ["命中频繁交易初筛,但当前未确认有效定投工单。"], ["补查关联工单渠道、状态和客户授权记录。"], ) def _combine_assessments( source: dict[str, Any], rules: list[str], assessments: list[dict[str, Any]], ) -> dict[str, Any]: if not assessments: assessments = [_assessment( VERDICT_CONTINUE_REVIEW, "低", ["预警没有可识别的规则编号。"], ["由风控专员根据完整证据人工复核。"], )] primary = max( assessments, key=lambda item: ( _VERDICT_SEVERITY[item["verdict"]], _CONFIDENCE_SEVERITY[item["confidence"]], ), ) return { "alert_no": source.get("alert_no"), "rule_codes": rules, "verdict": primary["verdict"], "confidence": primary["confidence"], "reasons": _deduplicate( reason for item in assessments for reason in item["reasons"] ), "review_actions": _deduplicate( action for item in assessments for action in item["review_actions"] ), "boundary": "仅为只读研判草案,不能替代风控专员人工复核和正式处置。", } def _assessment( verdict: str, confidence: str, reasons: list[str], review_actions: list[str], ) -> dict[str, Any]: return { "verdict": verdict, "confidence": confidence, "reasons": reasons, "review_actions": review_actions, } def _missing_traces( product: dict[str, Any], work_order: dict[str, Any], ) -> list[str]: checks = ( ("风险揭示确认", "risk_disclosure_required", "risk_disclosure_ack_at"), ("二次确认", "second_confirmation_required", "second_confirmation_at"), ("录音留痕", "recording_required", "recording_reference"), ) missing: list[str] = [] for label, required_field, evidence_field in checks: if bool(product.get(required_field)) and not work_order.get(evidence_field): missing.append(label) return missing def _latest_successful_login( value: Any, confirmed_at: datetime | None, ) -> dict[str, Any] | None: if not isinstance(value, list): return None candidates = [] for item in value: if not isinstance(item, dict): continue if item.get("login_result") != "成功": continue login_at = _datetime(item.get("login_at")) if login_at is None: continue if confirmed_at is not None and login_at > confirmed_at: continue candidates.append((login_at, item)) if not candidates: return None return max(candidates, key=lambda item: item[0])[1] def _rule_codes(value: dict[str, Any]) -> list[str]: raw = value.get("rule_codes") or value.get("trigger_rule_codes") or [] if isinstance(raw, str): raw = [raw] if not isinstance(raw, (list, tuple)): return [] return _deduplicate(str(item).upper() for item in raw if item) def _risk_level_number(value: Any, prefix: str) -> int | None: if not isinstance(value, str): return None matched = re.fullmatch(rf"{re.escape(prefix)}(\d+)", value.strip().upper()) return int(matched.group(1)) if matched else None def _age(value: Any) -> int | None: birth_date = _date(value) if birth_date is None: return None # 用**北京时间**的今天。原先这里用服务器 date.today()(东八区的机器上是北京日期, # 但不保证),而扫描侧用 UTC 日期——两处口径不一致会让同一客户在生日边界上差一岁 # (docs/25 P1 #6)。统一走 timeutil。 today = local_date(datetime.now(UTC)) return today.year - birth_date.year - ( (today.month, today.day) < (birth_date.month, birth_date.day) ) def _date(value: Any) -> date | None: if isinstance(value, date) and not isinstance(value, datetime): return value if isinstance(value, datetime): return value.date() if not isinstance(value, str) or not value.strip(): return None try: return date.fromisoformat(value.strip()[:10]) except ValueError: return None def _datetime(value: Any) -> datetime | None: if isinstance(value, datetime): return value if not isinstance(value, str) or not value.strip(): return None try: return datetime.fromisoformat(value.strip().replace("Z", "+00:00")) except ValueError: return None def _decimal(value: Any) -> Decimal | None: if value is None or isinstance(value, bool): return None try: return Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): return None def _mapping(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} def _deduplicate(values: Any) -> list[str]: result: list[str] = [] for value in values: if value not in result: result.append(value) return result