fix(risk): RW-012 研判补上"≥3 倍历史均值"复核,顺带修一处除零

docs/25 P2。核实后发现**根因不在研判侧偷懒,而在快照缺数据**:

- 扫描侧生成 RW-012 要求 verage > 0 且 amount >= average * 3(risk_scan_service.py:224);
- 研判侧 _assess_rw012 只看了年龄、金额、非常用设备,**完全没核均值** —— 于是达不到
  3 倍的交易也会被判"证据支持风险"。它自己的建议文本写着"继续核实一年期历史交易均值":
  作者知道该看,只是当时确实没有数据可看。
- 原因:扫描侧写进快照的只有 product_id / age / device_id,**没有均值**
  (对比 RW-003 在 :124 就写了 ratio)。

**两侧一起改**:

1. 扫描侧补写 verage_amount 与 
atio(用 ratio 与 RW-003 的快照风格保持一致)。
2. 研判侧据此复核:
atio 缺失 → CONTINUE_REVIEW("无法复核 3 倍门槛",
   不再顺着"非常用设备"定案);
atio < 3 → SUSPECTED_FALSE_POSITIVE;否则走原有逻辑。
3. **顺带修一处除零**:verage == 0 时 mount < 0*3 恒为 False,会一路走到
   mount / average 直接崩。改为同时挡住 verage_amount <= 0。

新增 tests/unit/service/test_risk_judgement_rw012.py(3 条):ratio 缺失时不落到风险成立、
ratio < 3 判误报并给出倍数、ratio >= 3 继续往下走。三个用例都停在登录记录判断之前,
不必构造复杂的登录证据。

ruff / mypy(136 文件) / 632 unit+contract / 29 integration 全绿。
This commit is contained in:
2026-09-11 13:48:08 +08:00
parent 85a1b33eff
commit c1f043684c
3 changed files with 91 additions and 1 deletions
+21
View File
@@ -220,6 +220,27 @@ def _assess_rw012(detail: dict[str, Any]) -> dict[str, Any]:
["核对证据快照是否来自已变更或已冲正的交易。"],
)
# 复核生成条件里的"≥ 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)
+14 -1
View File
@@ -221,7 +221,14 @@ class RiskRuleEngine:
FundTransaction.confirmed_at >= transaction.confirmed_at - timedelta(days=365),
)
)
if average is None or transaction.amount < Decimal(average) * 3:
# `average <= 0` 也必须挡住:均值为 0 时 `amount < 0*3` 恒为 False,
# 会一路走到下面算 `amount / average` 直接除零。
average_amount = Decimal(average) if average is not None else None
if (
average_amount is None
or average_amount <= 0
or transaction.amount < average_amount * 3
):
continue
login = await self.session.scalar(
select(RiskLoginRecord)
@@ -251,6 +258,12 @@ class RiskRuleEngine:
"product_id": transaction.product_id,
"age": age,
"device_id": login.device_id,
# 把生成条件用到的均值也写进快照。原先这里只有 age / device_id,
# 研判侧要复核"是否真达到 3 倍历史均值"却无从下手,只能跳过这一条、
# 直接让"非常用设备"定案(docs/25 P2:研判漏阈值条件)。
# 写 ratio 与 RW-003 的快照风格保持一致。
"average_amount": str(average_amount),
"ratio": str(transaction.amount / average_amount),
},
))
return alerts