"""风控预警只读研判草案。 本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、 放行、结案或升级等人工处置动作。 """ 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 = "可考虑放行" 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]: if rule == "RW-018": return _assessment( VERDICT_RELEASE, "高", ["命中低优先级频繁交易初筛,现有摘要显示交易来自有效定投工单。"], ["核验定投工单状态、交易周期和客户授权记录。"], ) 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 _assess_detail_rule(rule: str, detail: dict[str, Any]) -> dict[str, Any]: 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} 元已不满足规则门槛。", ], ["核对证据快照是否来自已变更或已冲正的交易。"], ) 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 == "定投": 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