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 全绿。
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"""扫描健壮性:脏数据不该让整批停摆,调度器不该"重启后再也不跑"。
|
||||
|
||||
这两条都属于"风控看起来在工作、其实没在跑"那类缺陷,现象上很难发现:
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user