Files
group_fqcd_jr/tests/unit/service/test_risk_scan_robustness.py
T
lzf_0626 24de6c34f7 fix(risk): 扫描健壮性——脏数据不再中断整批,调度器重启后仍会执行
docs/25 P0 的后两项,都属于"风控看起来在工作、其实没在跑"那一类。

**② 一条脏数据中断整批**
_level_value 原先直接 int(level.replace(prefix, "")):等级字段只要有一条不是
R1-R5 / C1-C5(例如写了「中风险」),就抛 ValueError 并冒到 scan() 的兜底 → **整批
rollback**,本次扫描前面已经生成的预警全部作废。数据脏属于运维问题,不该升级成
"整个风控停摆"。

改为返回 int | None;调用点跳过该条并记 warning(带上 transaction_id 与两个原始值,
便于运维直接定位)。顺带把 
eplace 换成 
emoveprefix:原先 "R2R" 会被错当成 2,
现在只去掉开头那一个前缀字符。

**③ 调度器重启后永不执行**
last_run_at 只存在内存里,重启后为 None,而 _is_due 此时返回
config.run_immediately(默认 False)⇒ 重启后 _is_due 恒为假,**调度器形同虚设,
而且没有任何告警**。

改为"从未跑过即视为 due":多跑一次的最坏后果是重复扫描,而扫描每条规则都先 _exists
查重、外层还有 MySQL 级锁;反过来"不跑"的后果可能是永远不跑。
config.run_immediately 不再承担"首次是否执行"的语义(它原本想表达"启动后别马上跑",
但那与"永远不跑"在实现上无法区分),字段保留以免破坏既有配置。

新增 tests/unit/service/test_risk_scan_robustness.py(6 条):脏数据返回 None 而不抛异常、
R2R 不被过度剥离、首次必 due(哪怕 run_immediately=False)、以及间隔前后的判定。

ruff / mypy(136 文件) / 622 unit+contract / 29 integration 全绿。
2026-09-11 13:35:58 +08:00

74 lines
3.0 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.
"""扫描健壮性:脏数据不该让整批停摆,调度器不该"重启后再也不跑"。
这两条都属于"风控看起来在工作、其实没在跑"那类缺陷,现象上很难发现:
1. `_level_value` 原先直接 `int(level.replace(prefix, ""))` —— 一条等级字段脏的数据
(比如写了「中风险」)会抛 ValueError,冒到 `scan()` 的兜底 → **整批 rollback**,
本次扫描前面已经生成的预警全部作废。
2. 调度器的 `last_run_at` 只在内存,重启后为 None,而 `_is_due` 此时返回
`config.run_immediately`(默认 False)→ **重启后永不执行**,且没有任何告警。
"""
import datetime as dt
from types import SimpleNamespace
from typing import Any, cast
from app.service.risk_scan_service import _level_value
from app.worker.risk_scan_scheduler import RiskScanSchedulerWorker
def test_level_value_parses_valid_levels() -> None:
assert _level_value("R2", "R") == 2
assert _level_value("C3", "C") == 3
assert _level_value("c5", "C") == 5 # 大小写不敏感
assert _level_value(" 2 ", "R") == 2 # 历史数据可能不带前缀
def test_level_value_returns_none_instead_of_raising_on_dirty_data() -> None:
"""脏数据返回 None,不抛异常 —— 这是不让整批扫描 rollback 的前提。"""
assert _level_value("中风险", "R") is None
assert _level_value("", "R") is None
assert _level_value("R", "R") is None
assert _level_value("R6", "R") == 6 # 越界值交给上层比较,解析本身只负责取数字
def test_level_value_does_not_over_strip() -> None:
"""用 removeprefix 而非 replace:`R2R` 不该被错当成 2。"""
assert _level_value("R2R", "R") is None
def _scheduler(*, last_run_at: dt.datetime | None) -> RiskScanSchedulerWorker:
"""只构造被测方法用到的那两个属性。"""
scheduler = object.__new__(RiskScanSchedulerWorker)
scheduler.last_run_at = last_run_at
return scheduler
def _config(*, run_immediately: bool, interval_minutes: int = 30) -> Any:
return SimpleNamespace(run_immediately=run_immediately, interval_minutes=interval_minutes)
_NOW = dt.datetime(2026, 9, 11, 12, 0, tzinfo=dt.UTC)
def test_first_run_is_due_even_when_run_immediately_is_false() -> None:
"""进程刚起来(last_run_at 为 None)必须视为 due,哪怕是 run_immediately=False。
原先这里返回 run_immediately,默认 False ⇒ 重启后永不执行。
"""
scheduler = cast(Any, _scheduler(last_run_at=None))
assert scheduler._is_due(_config(run_immediately=False), _NOW) is True
def test_not_due_before_interval_elapses() -> None:
scheduler = cast(Any, _scheduler(last_run_at=_NOW - dt.timedelta(minutes=10)))
assert scheduler._is_due(_config(run_immediately=False), _NOW) is False
def test_due_after_interval_elapses() -> None:
scheduler = cast(Any, _scheduler(last_run_at=_NOW - dt.timedelta(minutes=31)))
assert scheduler._is_due(_config(run_immediately=False), _NOW) is True