From c1f043684c0dd45cc4cbf46a2b9c0f2ab9c546c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Fri, 11 Sep 2026 13:48:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(risk):=20RW-012=20=E7=A0=94=E5=88=A4?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A"=E2=89=A53=20=E5=80=8D=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=9D=87=E5=80=BC"=E5=A4=8D=E6=A0=B8=EF=BC=8C=E9=A1=BA?= =?UTF-8?q?=E5=B8=A6=E4=BF=AE=E4=B8=80=E5=A4=84=E9=99=A4=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全绿。 --- app/service/risk_judgement_service.py | 21 +++++++ app/service/risk_scan_service.py | 15 ++++- .../unit/service/test_risk_judgement_rw012.py | 56 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/unit/service/test_risk_judgement_rw012.py diff --git a/app/service/risk_judgement_service.py b/app/service/risk_judgement_service.py index 14484c2..0dd8ee2 100644 --- a/app/service/risk_judgement_service.py +++ b/app/service/risk_judgement_service.py @@ -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) diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py index 0cbf16a..1ad0094 100644 --- a/app/service/risk_scan_service.py +++ b/app/service/risk_scan_service.py @@ -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 diff --git a/tests/unit/service/test_risk_judgement_rw012.py b/tests/unit/service/test_risk_judgement_rw012.py new file mode 100644 index 0000000..bc0a9a6 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_rw012.py @@ -0,0 +1,56 @@ +"""RW-012 研判必须复核"≥ 3 倍历史均值"这条生成条件。 + +docs/25 P2:扫描侧生成预警要求 `average > 0 且 amount >= average * 3` +(`risk_scan_service.py`),而研判侧的 `_assess_rw012` 只看了年龄、金额、非常用设备, +**完全没核均值** —— 于是达不到 3 倍的交易也会被判"证据支持风险"。 +讽刺的是它的建议文本写着"继续核实一年期历史交易均值":作者知道该看,只是当时确实 +没有数据可看。 + +根因是快照里没有均值(原先只写 `product_id` / `age` / `device_id`)。本次两侧一起改: +扫描侧补写 `average_amount` 与 `ratio`,研判侧据此复核。 + +这三个用例都停在登录记录判断**之前**,所以不必构造合法的登录证据。 +""" + +from typing import Any + +from app.service.risk_judgement_service import ( + VERDICT_CONTINUE_REVIEW, + VERDICT_SUSPECTED_FALSE_POSITIVE, + _assess_rw012, +) + + +def _detail(*, ratio: str | None) -> dict[str, Any]: + """一条已满足年龄与金额门槛的 RW-012,ratio 由用例决定(None 表示快照里没有)。""" + snapshot: dict[str, Any] = {} if ratio is None else {"ratio": ratio} + return { + "customer": {"birth_date": "1950-01-01"}, # 70 岁以上 + "transaction": {"amount": "500000", "confirmed_at": "2026-09-10T04:00:00"}, + "evidence_snapshot": snapshot, + "login_records": [], + } + + +def test_missing_ratio_does_not_fall_through_to_risk_supported() -> None: + """快照里没有均值时不能顺着"非常用设备"就定案 —— 应该继续复核。""" + result = _assess_rw012(_detail(ratio=None)) + + assert VERDICT_CONTINUE_REVIEW in str(result) + + +def test_ratio_below_three_is_a_false_positive() -> None: + """不到 3 倍均值就不满足规则的生成条件 —— 这是本次补上的判断。""" + result = _assess_rw012(_detail(ratio="2.5")) + + assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result) + assert "2.50" in str(result) + + +def test_ratio_at_or_above_three_passes_the_threshold_check() -> None: + """达到 3 倍时应继续往下走(本用例没有登录记录,故停在"补查登录记录")。""" + for ratio in ("3", "6.2"): + result = _assess_rw012(_detail(ratio=ratio)) + + assert VERDICT_CONTINUE_REVIEW in str(result), f"ratio={ratio} 不该被判成误报" + assert VERDICT_SUSPECTED_FALSE_POSITIVE not in str(result)