Files
group_fqcd_jr/app/service/risk_judgement_service.py
T
lzf_0626 85a1b33eff fix(risk): RW-018 研判的两个口径缺陷——列表级不再无条件放行,详情级与扫描侧对齐
docs/25 P2。核实后发现这两条其实是**同一处的两个表现**,所以一起修。

**① 列表级无条件放行**
_assess_list_rule 收到 item 参数**却完全不看它**,对 RW-018 一律返回"可考虑放行",
连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。于是渠道不匹配、或证据里
根本没有工单信息的预警,在列表层就被标成可放行 —— 而列表正是风控专员最先看到的一屏
(详情层另有判断,但那要等人点进去)。

现在按 item["evidence_snapshot"]["channel"] 判断。快照里确实有渠道:扫描侧写入了它
(risk_scan_service.py:308),列表行也带出了整个 snapshot(risk_repository.py:955),
所以这个校验是可行的,此前只是没做。

**② 详情级与扫描侧口径不一致**
扫描按 work_order.channel in {"定投", "自动定投"} 生成预警(risk_scan_service.py:294),
详情级却只认 "定投"。于是"自动定投"的预警会出现**"扫描认为有效、详情认为未确认"**的
自相矛盾。

抽出 DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"}) 作为唯一口径,
列表级与详情级都改用它;改这份常量即同时影响两侧。

新增 tests/unit/service/test_risk_judgement_rw018.py(6 条):常量覆盖扫描侧全部渠道、
列表级在无渠道 / 渠道不符时**不得**放行、两个合法渠道在列表级与详情级都放行、
详情级对无关渠道不放行。

ruff / mypy(136 文件) / 629 unit+contract 全绿。
2026-09-11 13:45:51 +08:00

460 lines
17 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.
"""风控预警只读研判草案。
本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、
放行、结案或升级等人工处置动作。
"""
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]:
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 _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 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