Files
group_fqcd_jr/app/worker/risk_scan_scheduler.py
T
lzf_0626 d896a906cd fix(risk): 规则扫描加跨进程锁——手工触发与定时扫描此前可以同时跑
docs/25 P2 最后一项。核实后分清了两层,报告没区分:

- **定时扫描是安全的**:risk_scan_scheduler.py 已有 MySQL 连接级咨询锁
  (GET_LOCK,锁名 jr_risk_scan_schedule),跨进程互斥。
- **HTTP 端点不安全**:POST /api/v1/risk/alerts/scan → RiskScanService.scan() 只用了
  **进程内** asyncio.Lock。多 Web worker、或 Worker 与 API 同时运行时形同虚设。

而扫描的幂等只有应用层的 _exists 查重 —— fin_risk_alert 的 trigger_rule_codes 是 JSON
数组,**无法建唯一索引兜底**(同一交易可命中多条规则,唯一键本应是"交易+规则",而规则
埋在 JSON 里)。所以两条路径并发时会同时查不到、同时插入,产生重复预警。

**改动**:

1. 把 mysql_scan_lock 与锁名移到 pp/infrastructure/db.py —— 端点与调度器**必须共用
   同一把锁**,放在基础设施层两个入口才都能引用(service 不该反向依赖 worker)。
   调度器改为从那里 import。
2. **端点层加锁**(controllers/risk.py 的 scan 端点):取不到锁就抛 RiskScanBusyError
   (与 service 内部那把进程内锁用同一错误类型与文案)。

**为什么不加在 RiskScanService.scan() 内部**:GET_LOCK 是**连接级**的,而调度器已经在
它自己的 session 上持锁;被两个入口共用的服务方法若再取同一把锁,取锁的连接并不是持锁的
那一个、必然返回 0 —— 会**把定时扫描自己挡死**。所以锁加在入口层,每个入口只取一次。

**实测**:
- 无人持锁时扫描 → **200**「规则扫描完成」
- 本进程先取得跨进程锁后再调端点 → **409**「规则扫描正在执行,请稍后重试」
  (同一进程内不同 session 也互斥,说明它是连接级的,正是跨进程所需)
- 释放后再调 → **200**,恢复正常

顺带第 4 次遇到 409 复用错误码 RUN_NOT_CANCELLABLE,语义不符;属 P3 待处理项。

ruff / mypy(136 文件) / 639 unit+contract 全绿。
2026-09-11 13:57:22 +08:00

186 lines
6.7 KiB
Python

"""风控规则扫描定时 Worker。
该进程不依赖 Web 进程生命周期,通过 MySQL 咨询锁保证同一时刻只有一个
调度者执行扫描。默认关闭,配置开启后才执行。
"""
from __future__ import annotations
import argparse
import asyncio
import logging
from collections.abc import Awaitable, Callable
from contextlib import AbstractAsyncContextManager
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory, engine, mysql_scan_lock
from app.model.audit import InteractionAudit
from app.service.risk_scan_schedule_config import (
RiskScanScheduleConfig,
load_risk_scan_schedule_config,
)
from app.service.risk_scan_service import RiskScanService
logger = logging.getLogger(__name__)
ConfigLoader = Callable[[], Awaitable[RiskScanScheduleConfig]]
ScanExecutor = Callable[[], Awaitable[dict[str, int | str]]]
AuditWriter = Callable[[str, dict[str, Any]], Awaitable[None]]
LockFactory = Callable[[], AbstractAsyncContextManager[bool]]
# 跨进程扫描锁已移到 `app/infrastructure/db.py`:端点与调度器**必须共用同一把锁**,
# 放在基础设施层两个入口才都能引用(service 不该反向依赖 worker)。
async def default_scan_executor() -> dict[str, int | str]:
context = RequestContext(
user_id="0",
trace_id=f"risk-scan-schedule-{uuid4()}",
roles=("system",),
permissions=("risk:alert:scan",),
data_scope="all",
portal="worker",
)
async with SessionFactory() as session:
return await RiskScanService(session).scan(context)
async def default_audit_writer(status: str, detail: dict[str, Any]) -> None:
async with SessionFactory() as session, session.begin():
session.add(
InteractionAudit(
actor_type="system",
actor_id=None,
target_customer_id=None,
portal="worker",
action_type=f"risk_scan_scheduled_{status}",
detail=detail,
created_at=datetime.now(UTC).replace(tzinfo=None),
)
)
class RiskScanSchedulerWorker:
def __init__(
self,
*,
config_loader: ConfigLoader = load_risk_scan_schedule_config,
scan_executor: ScanExecutor = default_scan_executor,
audit_writer: AuditWriter = default_audit_writer,
lock_factory: LockFactory = mysql_scan_lock,
now: Callable[[], datetime] | None = None,
) -> None:
self.config_loader = config_loader
self.scan_executor = scan_executor
self.audit_writer = audit_writer
self.lock_factory = lock_factory
self.now = now or (lambda: datetime.now(UTC))
self.last_run_at: datetime | None = None
async def run_once(self, *, force: bool = False) -> bool:
config = await self.config_loader()
if not config.enabled:
return False
current = self.now()
if not force and not self._is_due(config, current):
return False
async with self.lock_factory() as acquired:
if not acquired:
logger.info("风控定时扫描由其他 Worker 执行,本轮跳过")
return False
return await self._execute_with_retry(config)
def _is_due(
self,
config: RiskScanScheduleConfig,
current: datetime,
) -> bool:
if self.last_run_at is None:
# 进程刚起来,不知道自己上次是什么时候跑的 —— `last_run_at` 只存在内存里。
# **保守地视为 due**:多跑一次的最坏后果是重复扫描,而扫描本身是幂等的
# (每条规则先 `_exists` 查重)并且有 MySQL 级锁;反过来"不跑"的后果可能是
# **永远不跑**:原先这里返回 `config.run_immediately`(默认 False),
# 重启之后 `_is_due` 恒为假,调度器形同虚设 —— 而且没有任何告警,
# 现场只会表现为"风控好像没在扫描"。
#
# `config.run_immediately` 因此不再承担"首次是否执行"的语义(它原本想表达的
# 是"启动后别马上跑",但那与"永远不跑"在实现上无法区分)。字段保留,
# 以免破坏既有配置。
return True
return current - self.last_run_at >= timedelta(minutes=config.interval_minutes)
async def _execute_with_retry(
self,
config: RiskScanScheduleConfig,
) -> bool:
attempts = config.retry_limit + 1
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
try:
result = await self.scan_executor()
self.last_run_at = self.now()
await self.audit_writer(
"succeeded",
{
**result,
"attempt": attempt,
"trace_id": f"risk-scan-schedule-{uuid4()}",
},
)
return True
except Exception as error:
last_error = error
logger.warning(
"风控定时扫描失败 attempt=%s/%s",
attempt,
attempts,
exc_info=True,
)
await self.audit_writer(
"failed",
{
"attempts": attempts,
"error_type": type(last_error).__name__ if last_error else "unknown",
"trace_id": f"risk-scan-schedule-{uuid4()}",
},
)
return False
async def serve(*, once: bool = False, force: bool = False) -> None:
worker = RiskScanSchedulerWorker()
try:
while True:
try:
await worker.run_once(force=force)
except Exception:
logger.warning("风控定时扫描 Worker 轮次失败", exc_info=True)
if once:
raise
if once:
return
await asyncio.sleep(get_settings().risk_scan_poll_seconds)
finally:
await engine.dispose()
def main() -> None:
parser = argparse.ArgumentParser(description="奶龙风控规则定时扫描 Worker")
parser.add_argument("--once", action="store_true", help="执行一轮后退出")
parser.add_argument("--force", action="store_true", help="忽略间隔,立即执行一次")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
try:
asyncio.run(serve(once=args.once, force=args.force))
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()