From 85a1b33effcfe0b5b2876cccfcad0704479062f2 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:45:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(risk):=20RW-018=20=E7=A0=94=E5=88=A4?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E4=B8=AA=E5=8F=A3=E5=BE=84=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=88=97=E8=A1=A8=E7=BA=A7=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=97=A0=E6=9D=A1=E4=BB=B6=E6=94=BE=E8=A1=8C=EF=BC=8C=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E7=BA=A7=E4=B8=8E=E6=89=AB=E6=8F=8F=E4=BE=A7=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全绿。 --- app/service/risk_judgement_service.py | 32 +++++++-- .../unit/service/test_risk_judgement_rw018.py | 65 +++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 tests/unit/service/test_risk_judgement_rw018.py diff --git a/app/service/risk_judgement_service.py b/app/service/risk_judgement_service.py index e3b3b33..14484c2 100644 --- a/app/service/risk_judgement_service.py +++ b/app/service/risk_judgement_service.py @@ -14,6 +14,12 @@ 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 = "证据支持风险" @@ -44,11 +50,27 @@ def assess_alert_detail(detail: dict[str, Any]) -> dict[str, Any]: 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_RELEASE, - "高", - ["命中低优先级频繁交易初筛,现有摘要显示交易来自有效定投工单。"], - ["核验定投工单状态、交易周期和客户授权记录。"], + VERDICT_SUSPECTED_FALSE_POSITIVE, + "中", + ["命中频繁交易初筛,但证据中未确认有效定投工单。"], + ["核验定投工单状态与客户授权记录后,由风控专员判断是否放行。"], ) if rule == "RW-015": return _assessment( @@ -261,7 +283,7 @@ def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]: def _assess_rw018(detail: dict[str, Any]) -> dict[str, Any]: work_order = _mapping(detail.get("work_order")) channel = work_order.get("channel") - if channel == "定投": + if channel in DIRECT_INVESTMENT_CHANNELS: return _assessment( VERDICT_RELEASE, "高", diff --git a/tests/unit/service/test_risk_judgement_rw018.py b/tests/unit/service/test_risk_judgement_rw018.py new file mode 100644 index 0000000..375a513 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_rw018.py @@ -0,0 +1,65 @@ +"""RW-018 研判的两个口径缺陷(docs/25 P2)。 + +1. **列表级无条件放行**:`_assess_list_rule` 收到 `item` 却完全不看它,对 RW-018 + 一律返回"可考虑放行",连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。 + 于是渠道不匹配、或证据里根本没有工单信息的预警,在列表层就被标成可放行 —— + 而列表正是风控专员最先看到的一屏。详情层另有判断,但那要等人点进去。 + +2. **详情级与扫描侧口径不一致**:扫描按 `work_order.channel in {"定投", "自动定投"}` + 生成 RW-018 预警(`risk_scan_service.py:294`),详情级却只认 `"定投"`。于是 + "自动定投"的预警会出现"扫描认为有效、详情认为未确认"的自相矛盾。 + +两条现在共用 `DIRECT_INVESTMENT_CHANNELS`,改这份常量即同时影响两侧。 +""" + +from typing import Any + +from app.service.risk_judgement_service import ( + DIRECT_INVESTMENT_CHANNELS, + VERDICT_RELEASE, + _assess_list_rule, + _assess_rw018, +) + + +def _item(**snapshot: Any) -> dict[str, Any]: + return {"alert_no": "ALTEST", "evidence_snapshot": snapshot} + + +def test_channels_cover_what_the_scanner_accepts() -> None: + """常量必须覆盖扫描侧接受的全部渠道,否则又会两侧打架。""" + assert DIRECT_INVESTMENT_CHANNELS == {"定投", "自动定投"} + + +def test_list_level_does_not_release_without_evidence() -> None: + """证据里没有渠道信息时不能放行 —— 这正是原先无条件放行的地方。""" + result = _assess_list_rule("RW-018", _item()) + + assert VERDICT_RELEASE not in str(result) + + +def test_list_level_does_not_release_for_an_unrelated_channel() -> None: + result = _assess_list_rule("RW-018", _item(channel="柜台")) + + assert VERDICT_RELEASE not in str(result) + + +def test_list_level_releases_for_every_direct_investment_channel() -> None: + for channel in sorted(DIRECT_INVESTMENT_CHANNELS): + result = _assess_list_rule("RW-018", _item(channel=channel)) + + assert VERDICT_RELEASE in str(result), f"{channel} 应当判定为可放行" + + +def test_detail_level_accepts_the_same_channels_as_the_scanner() -> None: + """详情级与扫描侧同口径 —— 原先详情只认"定投","自动定投"会被判成未确认。""" + for channel in sorted(DIRECT_INVESTMENT_CHANNELS): + result = _assess_rw018({"work_order": {"channel": channel}}) + + assert VERDICT_RELEASE in str(result), f"{channel} 在详情级也应判为可放行" + + +def test_detail_level_rejects_a_channel_the_scanner_would_not_use() -> None: + result = _assess_rw018({"work_order": {"channel": "柜台"}}) + + assert VERDICT_RELEASE not in str(result)