From 3ab7f33d64cfc3d0a1af2734b20c4d7ccbd997fb 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:51:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(risk):=20=E9=A2=84=E8=AD=A6=E8=A1=8C?= =?UTF-8?q?=E5=B8=A6=E5=87=BA=20close=5Freason=EF=BC=8C=E6=97=A5=E6=8A=A5?= =?UTF-8?q?=E7=9A=84"=E8=AF=AF=E6=8A=A5=E5=8E=9F=E5=9B=A0"=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=98=AF=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/25 P2。核实确认报告准确,而且这一处缺口造成两个症状: _alert_row 是**列表 / 详情 / 日报共用**的行构造器 (risk_repository.py:174 / :276 / :321),而它的字段列表里没有 close_reason。于是: - 日报的"误报原因"分布恒为"未填写" (risk_daily_report_service.py:139 用 item.get("close_reason") or "未填写"); - :243 的明细里同一字段同样拿不到值。 断的是"关闭误报时写入原因"这条链路的**下半段**:risk_action_service.py:70 把原因赋给 lert.close_reason、也确实存进了库(app/model/fund.py:368 有该字段),只是读取时没带出来。 修法是一行:在 _alert_row 里补上 close_reason。三个调用点同时受益;读取方都是风控侧 接口(需要 risk:alert:read),不涉及客户可见面。 新增 tests/unit/repository/test_risk_alert_row_close_reason.py(2 条):关闭原因出现在行里; 未关闭时为 None 但**键必须在** —— 读取方靠 or "未填写" 兜底,键一旦缺失就永远只能走 兜底分支,那正是修复前的状态。 ruff / mypy(136 文件) / 639 unit+contract / 29 integration 全绿。 --- app/repository/risk_repository.py | 7 ++ .../test_risk_alert_row_close_reason.py | 64 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/unit/repository/test_risk_alert_row_close_reason.py diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py index 9d3e269..34f037c 100644 --- a/app/repository/risk_repository.py +++ b/app/repository/risk_repository.py @@ -962,6 +962,13 @@ class RiskRepository: "is_escalated": bool(alert.is_escalated), "escalated_at": alert.escalated_at, "evidence_archived": bool(snapshot.get("evidence_archive")), + # 关闭误报时写入的原因(risk_action_service.py:70 赋值给 alert.close_reason)。 + # 原先这一行不在,导致两个症状同一个原因 —— 行里根本没带出来: + # · 日报的"误报原因"分布恒为"未填写" + # (risk_daily_report_service.py:139 用 `item.get("close_reason") or "未填写"`); + # · `:243` 的明细里同一字段同样拿不到值。 + # 读取方都是风控侧接口(需要 risk:alert:read),带上它不涉及客户可见面。 + "close_reason": alert.close_reason, "created_at": alert.created_at, "updated_at": alert.updated_at, } diff --git a/tests/unit/repository/test_risk_alert_row_close_reason.py b/tests/unit/repository/test_risk_alert_row_close_reason.py new file mode 100644 index 0000000..bc5fd96 --- /dev/null +++ b/tests/unit/repository/test_risk_alert_row_close_reason.py @@ -0,0 +1,64 @@ +"""预警行必须带出 `close_reason`,否则日报的"误报原因"永远是空。 + +docs/25 P2。`_alert_row` 是**列表 / 详情 / 日报共用**的行构造器 +(`risk_repository.py:174` / `:276` / `:321`),而它的字段列表里原先没有 `close_reason`。 +后果是两个症状同一个原因 —— 行里根本没带出来: + +- 日报的"误报原因"分布恒为"未填写" + (`risk_daily_report_service.py:139` 用 `item.get("close_reason") or "未填写"`); +- `:243` 的明细里同一字段同样为空。 + +断掉的是"关闭误报时写入原因"这条链路的下半段:`risk_action_service.py:70` 把原因赋给 +`alert.close_reason`、也存进了库,只是读取时没带出来。 +""" + +from types import SimpleNamespace +from typing import Any + +from app.repository.risk_repository import RiskRepository + +CLOSED_REASON = "已核验为本人自动定投计划。" + + +def _alert(**overrides: Any) -> Any: + base: dict[str, Any] = { + "alert_no": "ALTEST", + "customer_id": 9001, + "alert_type": "大额频繁交易", + "alert_level": "高", + "trigger_rule_codes": ["RW-007"], + "evidence_summary": "摘要", + "evidence_snapshot": {}, + "priority_score": 90, + "event_status": "刚刚发生", + "status": "已关闭", + "ack_status": "已确认", + "ack_at": None, + "due_at": None, + "is_escalated": 0, + "escalated_at": None, + "close_reason": CLOSED_REASON, + "created_at": None, + "updated_at": None, + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_alert_row_carries_close_reason() -> None: + """关闭原因必须出现在行里 —— 它是日报"误报原因"的唯一来源。""" + row = RiskRepository._alert_row(_alert(), None, None, None, None) + + assert row["close_reason"] == CLOSED_REASON + + +def test_alert_row_keeps_the_key_even_when_there_is_no_reason() -> None: + """未关闭的预警没有原因,但**键必须在**。 + + 读取方用 `item.get("close_reason") or "未填写"` 兜底,所以 None 与缺键在展示上等价; + 可一旦键不存在,读取方就永远只能走到兜底分支 —— 那正是本次修复前的状态。 + """ + row = RiskRepository._alert_row(_alert(close_reason=None), None, None, None, None) + + assert "close_reason" in row + assert row["close_reason"] is None