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 全绿。
234 lines
9.2 KiB
Python
234 lines
9.2 KiB
Python
"""风控只读查询接口。"""
|
|
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from datetime import datetime, time
|
|
|
|
from fastapi import APIRouter, Depends, File, Path, UploadFile
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.responses import StreamingResponse
|
|
|
|
from app.api.dependencies.auth import build_request_context
|
|
from app.api.dependencies.database import get_session
|
|
from app.api.dependencies.rate_limit import enforce_rate_limit
|
|
from app.api.schemas.risk import (
|
|
RiskAlertEscalationRequest,
|
|
RiskAlertExclusionRequest,
|
|
RiskAlertPageQuery,
|
|
RiskAlertResolutionRequest,
|
|
RiskDailyReportGenerateRequest,
|
|
RiskDailyReportMailRequest,
|
|
RiskEvidencePageQuery,
|
|
RiskEvidenceSource,
|
|
RiskNotificationPageQuery,
|
|
)
|
|
from app.core.contracts import RequestContext
|
|
from app.infrastructure.db import mysql_scan_lock
|
|
from app.service.risk_action_service import RiskActionService
|
|
from app.service.risk_daily_report_mail_service import RiskDailyReportMailService
|
|
from app.service.risk_daily_report_service import RiskDailyReportService
|
|
from app.service.risk_evidence_archive_service import RiskEvidenceArchiveService
|
|
from app.service.risk_notification_service import RiskNotificationService
|
|
from app.service.risk_query_service import RiskQueryService
|
|
from app.service.risk_scan_service import RiskScanBusyError, RiskScanService
|
|
|
|
router = APIRouter(
|
|
prefix="/api/v1/risk",
|
|
tags=["risk"],
|
|
dependencies=[Depends(enforce_rate_limit)],
|
|
)
|
|
|
|
|
|
@router.get("/overview")
|
|
async def risk_overview(
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskQueryService(session).overview(context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.get("/alerts")
|
|
async def list_risk_alerts(
|
|
query: RiskAlertPageQuery = Depends(), # noqa: B008
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskQueryService(session).list_alerts(context, query)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/scan")
|
|
async def scan_risk_alerts(
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
# 手工触发的扫描必须与定时扫描互斥,否则两条路径会同时查不到重复、同时插入。
|
|
# 锁加在**入口层**而不是 `RiskScanService.scan()` 内部:`GET_LOCK` 是连接级的,
|
|
# 而调度器已在它自己的 session 上持锁 —— 被两个入口共用的服务方法若再取同一把锁,
|
|
# 取锁的连接不是持锁的那一个、必然失败,会**把定时扫描自己挡死**。
|
|
async with mysql_scan_lock() as acquired:
|
|
if not acquired:
|
|
raise RiskScanBusyError("规则扫描正在执行,请稍后重试")
|
|
data = await RiskScanService(session).scan(context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/{alert_no}/acknowledgements")
|
|
async def acknowledge_risk_alert(
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskActionService(session).acknowledge(alert_no, context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/{alert_no}/investigations")
|
|
async def investigate_risk_alert(
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskActionService(session).investigate(alert_no, context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/{alert_no}/exclusions")
|
|
async def exclude_risk_alert(
|
|
payload: RiskAlertExclusionRequest,
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskActionService(session).exclude(alert_no, payload.reason, context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/{alert_no}/resolutions")
|
|
async def resolve_risk_alert(
|
|
payload: RiskAlertResolutionRequest,
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskActionService(session).resolve(alert_no, payload.resolution, context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/{alert_no}/escalations")
|
|
async def escalate_risk_alert(
|
|
payload: RiskAlertEscalationRequest,
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskActionService(session).escalate(alert_no, payload.reason, context)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/alerts/{alert_no}/evidence")
|
|
async def archive_risk_evidence(
|
|
evidence_file: UploadFile = File(...), # noqa: B008
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
try:
|
|
data = await RiskEvidenceArchiveService(session).archive(alert_no, evidence_file, context)
|
|
return _envelope(data, context)
|
|
finally:
|
|
await evidence_file.close()
|
|
|
|
|
|
@router.get("/alerts/{alert_no}")
|
|
async def get_risk_alert(
|
|
alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskQueryService(session).get_alert_detail(context, alert_no.strip())
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.get("/evidence/{source}")
|
|
async def list_risk_evidence(
|
|
source: RiskEvidenceSource = Path(), # noqa: B008
|
|
query: RiskEvidencePageQuery = Depends(), # noqa: B008
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskQueryService(session).list_evidence(context, source, query)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.get("/notifications")
|
|
async def list_risk_notifications(
|
|
query: RiskNotificationPageQuery = Depends(), # noqa: B008
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskNotificationService(session).list_notifications(context, query)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/daily-report")
|
|
async def generate_risk_daily_report(
|
|
payload: RiskDailyReportGenerateRequest,
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> dict[str, object]:
|
|
report_time = (
|
|
datetime.combine(payload.report_date, time.min)
|
|
if payload.report_date is not None
|
|
else None
|
|
)
|
|
data = await RiskDailyReportService(session).generate(context, report_time)
|
|
return _envelope(data, context)
|
|
|
|
|
|
@router.post("/daily-report/stream")
|
|
async def stream_risk_daily_report(
|
|
payload: RiskDailyReportGenerateRequest,
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
|
) -> StreamingResponse:
|
|
report_time = (
|
|
datetime.combine(payload.report_date, time.min)
|
|
if payload.report_date is not None
|
|
else None
|
|
)
|
|
|
|
async def events() -> AsyncIterator[str]:
|
|
async for event in RiskDailyReportService(session).stream(context, report_time):
|
|
event_type = str(event.get("type", "message"))
|
|
payload = json.dumps(event, ensure_ascii=False, default=str)
|
|
yield f"event: {event_type}\ndata: {payload}\n\n"
|
|
|
|
return StreamingResponse(
|
|
events(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@router.post("/daily-report/mail")
|
|
async def send_risk_daily_report_mail(
|
|
payload: RiskDailyReportMailRequest,
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
) -> dict[str, object]:
|
|
data = await RiskDailyReportMailService().send(
|
|
payload.recipients,
|
|
payload.subject,
|
|
payload.content,
|
|
context=context,
|
|
)
|
|
return _envelope(data, context)
|
|
|
|
|
|
def _envelope(data: object, context: RequestContext) -> dict[str, object]:
|
|
return {
|
|
"data": data,
|
|
"meta": {"trace_id": context.trace_id},
|
|
}
|