Files
group_fqcd_jr/app/service/risk_judgement_service.py
T
lzf_0626 8ac0b794ff fix(risk): 收敛剩余的时区口径(REST 时间参数、年龄、日报日期字段)
接 b3da1b6。上一条只改了凌晨规则与日报日界,剩下几处一并收掉:

1. risk_query_service.py:77-78:REST 的 start_time/end_time 是**裸 datetime**,
   原先原样透传去比库内 UTC 列,而 Agent 路径本来就带时区
   (risk_natural_language.py:117)——同一条筛选条件在界面与对话里会查出不同结果。
   timeutil 新增 from_local:裸值按**北京时间**解释(面向中国客户的业务系统,
   填表人的预期就是本地时间),带时区的按其自身时区处理。它与 to_utc_naive 的区别
   正在裸值上:取库里的值用后者,接客户端输入用这个。
2. risk_scan_service.py 与 risk_judgement_service.py 的 _age:一处用 UTC 日期、
   一处用服务器 date.today(),生日边界上同一客户会差一岁、65 岁阈值可能翻面。
   统一走 local_date(北京时间)。
3. risk_daily_report_service.py:122 的 report_date 与 :244 的 created_today:
   原先取 UTC 日期,北京 08:00 之前会把"今天新增的预警"算成昨天。

ruff / mypy(135 文件) / 603 unit+contract 全绿。
2026-09-11 12:55:00 +08:00

438 lines
16 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 = "可考虑放行"
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