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:
2026-09-11 13:35:58 +08:00
parent d331c817ac
commit 24de6c34f7
3 changed files with 111 additions and 4 deletions
+27 -3
View File
@@ -146,7 +146,21 @@ class RiskRuleEngine:
or await self._exists(transaction.id, "RW-007")
):
continue
gap = _level_value(product.risk_level, "R") - _level_value(customer.investor_type, "C")
product_level = _level_value(product.risk_level, "R")
customer_level = _level_value(customer.investor_type, "C")
if product_level is None or customer_level is None:
# 等级字段脏(不是 R1-R5 / C1-C5)。**跳过这一条**,而不是让整个扫描挂掉:
# 原先 `_level_value` 直接 `int(...)`,一条脏数据抛 ValueError 会冒到
# `scan()` 的兜底 → 整批 rollback,前面规则扫出来的预警全部白做。
# 数据脏属于运维问题,不该升级成"整个风控停摆"。
logger.warning(
"跳过等级字段异常的记录:transaction_id=%s risk_level=%r investor_type=%r",
transaction.id,
product.risk_level,
customer.investor_type,
)
continue
gap = product_level - customer_level
missing_trace = (
(
product.risk_disclosure_required
@@ -486,5 +500,15 @@ def _age(birth_date: date | None) -> int:
)
def _level_value(level: str, prefix: str) -> int:
return int(level.replace(prefix, ""))
def _level_value(level: str, prefix: str) -> int | None:
"""把 "R2" / "C3" 解析成 2 / 3;格式不符返回 None,**不再抛异常**。
原先写的是 `int(level.replace(prefix, ""))`:一条脏数据(等级字段写了「中风险」之类)
就会抛 ValueError,一路冒到 `scan()` 的兜底 → **整批 rollback**,这次扫描前面已经
生成的预警全部作废。数据脏是运维问题,不该升级成"整个风控停摆"。
用 `removeprefix` 而不是 `replace`:只去掉开头那一个前缀字符,
`"R2R"` 这种脏值不会被错当成 2。(没有前缀的 `"2"` 仍能解析——历史数据可能不带前缀。)
"""
digits = level.strip().upper().removeprefix(prefix.upper())
return int(digits) if digits.isdigit() else None