2026-09-10 21:03:44 +08:00
|
|
|
|
"""风控只读查询接口。"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
2026-09-11 14:12:56 +08:00
|
|
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
2026-09-10 21:03:44 +08:00
|
|
|
|
from datetime import datetime, time
|
2026-09-11 14:01:16 +08:00
|
|
|
|
from typing import Any
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
2026-09-11 14:12:56 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, File, Header, Path, Request, UploadFile
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 14:08:55 +08:00
|
|
|
|
from app.api.dependencies.negotiation import accepts_event_stream
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-09-11 15:21:36 +08:00
|
|
|
|
from app.api.views.envelope import envelope as _envelope
|
|
|
|
|
|
from app.api.views.envelope import list_envelope as _list_envelope
|
2026-09-10 21:03:44 +08:00
|
|
|
|
from app.core.contracts import RequestContext
|
2026-09-11 14:08:55 +08:00
|
|
|
|
from app.core.errors import SseNotAcceptableError
|
2026-09-11 13:57:22 +08:00
|
|
|
|
from app.infrastructure.db import mysql_scan_lock
|
2026-09-11 14:12:56 +08:00
|
|
|
|
from app.service.api_transaction_service import ApiTransactionService
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 13:57:22 +08:00
|
|
|
|
from app.service.risk_scan_service import RiskScanBusyError, RiskScanService
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(
|
|
|
|
|
|
prefix="/api/v1/risk",
|
|
|
|
|
|
tags=["risk"],
|
|
|
|
|
|
dependencies=[Depends(enforce_rate_limit)],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 14:12:56 +08:00
|
|
|
|
async def _idempotent_write(
|
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
|
context: RequestContext,
|
|
|
|
|
|
key: str | None,
|
|
|
|
|
|
scope: str,
|
|
|
|
|
|
body: Any,
|
|
|
|
|
|
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""风控写接口的统一幂等入口(`docs/05` §5.1、§5.2、§13.2)。
|
|
|
|
|
|
|
|
|
|
|
|
`scope` 用**实际**路径(含 `alert_no`):§5.1 的幂等范围是
|
|
|
|
|
|
`user_id + method + normalized_path + idempotency_key`,把路径参数折成模板会让
|
|
|
|
|
|
同一个键在不同预警之间互相回放 —— 那是把两次不同资源的操作当成一次。
|
|
|
|
|
|
|
|
|
|
|
|
幂等记录与业务写入同事务(见 `ApiTransactionService.execute_in`),重复请求直接
|
|
|
|
|
|
回放 `response_json`,不会二次驱动状态机。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await ApiTransactionService().execute_in(session, context, scope, key, body, action)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 23:51:45 +08:00
|
|
|
|
def _risk_list_envelope(page: dict[str, Any], context: RequestContext) -> dict[str, object]:
|
|
|
|
|
|
"""在统一列表信封上补充风控列表总数和页大小。"""
|
|
|
|
|
|
response = _list_envelope(page, context)
|
|
|
|
|
|
meta = response["meta"]
|
|
|
|
|
|
if isinstance(meta, dict):
|
|
|
|
|
|
meta["total"] = int(page.get("total") or 0)
|
|
|
|
|
|
meta["page_size"] = int(page.get("page_size") or 0)
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 21:03:44 +08:00
|
|
|
|
@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)
|
2026-09-13 23:51:45 +08:00
|
|
|
|
return _risk_list_envelope(data, context)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/alerts/scan")
|
|
|
|
|
|
async def scan_risk_alerts(
|
|
|
|
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
|
|
|
|
session: AsyncSession = Depends(get_session), # noqa: B008
|
2026-09-11 14:12:56 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-10 21:03:44 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 14:12:56 +08:00
|
|
|
|
async def run(inner: AsyncSession) -> dict[str, Any]:
|
|
|
|
|
|
# 手工触发的扫描必须与定时扫描互斥,否则两条路径会同时查不到重复、同时插入。
|
|
|
|
|
|
# 锁加在**入口层**而不是 `RiskScanService.scan()` 内部:`GET_LOCK` 是连接级的,
|
|
|
|
|
|
# 而调度器已在它自己的 session 上持锁 —— 被两个入口共用的服务方法若再取同一把锁,
|
|
|
|
|
|
# 取锁的连接不是持锁的那一个、必然失败,会**把定时扫描自己挡死**。
|
|
|
|
|
|
async with mysql_scan_lock() as acquired:
|
|
|
|
|
|
if not acquired:
|
|
|
|
|
|
raise RiskScanBusyError("规则扫描正在执行,请稍后重试")
|
2026-09-12 15:56:50 +08:00
|
|
|
|
return await RiskScanService.from_settings(inner).scan(context)
|
2026-09-11 14:12:56 +08:00
|
|
|
|
|
|
|
|
|
|
data = await _idempotent_write(
|
|
|
|
|
|
session, context, key, "POST /api/v1/risk/alerts/scan", {}, run
|
|
|
|
|
|
)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 14:12:56 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-10 21:03:44 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 14:12:56 +08:00
|
|
|
|
data = await _idempotent_write(
|
|
|
|
|
|
session,
|
|
|
|
|
|
context,
|
|
|
|
|
|
key,
|
|
|
|
|
|
f"POST /api/v1/risk/alerts/{alert_no}/acknowledgements",
|
|
|
|
|
|
{},
|
|
|
|
|
|
lambda inner: RiskActionService(inner).acknowledge(alert_no, context),
|
|
|
|
|
|
)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 14:12:56 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-10 21:03:44 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 14:12:56 +08:00
|
|
|
|
data = await _idempotent_write(
|
|
|
|
|
|
session,
|
|
|
|
|
|
context,
|
|
|
|
|
|
key,
|
|
|
|
|
|
f"POST /api/v1/risk/alerts/{alert_no}/investigations",
|
|
|
|
|
|
{},
|
|
|
|
|
|
lambda inner: RiskActionService(inner).investigate(alert_no, context),
|
|
|
|
|
|
)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 14:12:56 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-10 21:03:44 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 14:12:56 +08:00
|
|
|
|
data = await _idempotent_write(
|
|
|
|
|
|
session,
|
|
|
|
|
|
context,
|
|
|
|
|
|
key,
|
|
|
|
|
|
f"POST /api/v1/risk/alerts/{alert_no}/exclusions",
|
|
|
|
|
|
{"reason": payload.reason},
|
|
|
|
|
|
lambda inner: RiskActionService(inner).exclude(alert_no, payload.reason, context),
|
|
|
|
|
|
)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 14:12:56 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-10 21:03:44 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 14:12:56 +08:00
|
|
|
|
data = await _idempotent_write(
|
|
|
|
|
|
session,
|
|
|
|
|
|
context,
|
|
|
|
|
|
key,
|
|
|
|
|
|
f"POST /api/v1/risk/alerts/{alert_no}/resolutions",
|
|
|
|
|
|
{"resolution": payload.resolution},
|
|
|
|
|
|
lambda inner: RiskActionService(inner).resolve(alert_no, payload.resolution, context),
|
|
|
|
|
|
)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
2026-09-11 14:12:56 +08:00
|
|
|
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
2026-09-10 21:03:44 +08:00
|
|
|
|
) -> dict[str, object]:
|
2026-09-11 14:12:56 +08:00
|
|
|
|
data = await _idempotent_write(
|
|
|
|
|
|
session,
|
|
|
|
|
|
context,
|
|
|
|
|
|
key,
|
|
|
|
|
|
f"POST /api/v1/risk/alerts/{alert_no}/escalations",
|
|
|
|
|
|
{"reason": payload.reason},
|
|
|
|
|
|
lambda inner: RiskActionService(inner).escalate(alert_no, payload.reason, context),
|
|
|
|
|
|
)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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)
|
2026-09-13 23:51:45 +08:00
|
|
|
|
return _risk_list_envelope(data, context)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-09-13 23:51:45 +08:00
|
|
|
|
return _risk_list_envelope(data, context)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-09-11 14:08:55 +08:00
|
|
|
|
request: Request,
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2026-09-11 14:08:55 +08:00
|
|
|
|
service = RiskDailyReportService(session)
|
|
|
|
|
|
# 鉴权与内容协商都必须在返回 StreamingResponse **之前**完成:`stream()` 是 async
|
|
|
|
|
|
# generator,函数体到第一次迭代才执行,而那时响应头已经发出去了 —— 403/406 只能
|
|
|
|
|
|
# 变成"200 + 半截流"(docs/25 P3 #24)。顺序与 §6.4 一致:先鉴权,后 Accept。
|
|
|
|
|
|
await service.authorize(context)
|
|
|
|
|
|
if not accepts_event_stream(request.headers.get("Accept")):
|
|
|
|
|
|
raise SseNotAcceptableError("Accept 必须接受 text/event-stream")
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
async def events() -> AsyncIterator[str]:
|
2026-09-11 14:08:55 +08:00
|
|
|
|
async for event in service.stream(context, report_time):
|
2026-09-10 21:03:44 +08:00
|
|
|
|
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]:
|
2026-09-11 13:39:50 +08:00
|
|
|
|
data = await RiskDailyReportMailService().send(
|
2026-09-10 21:03:44 +08:00
|
|
|
|
payload.recipients,
|
|
|
|
|
|
payload.subject,
|
|
|
|
|
|
payload.content,
|
2026-09-11 13:39:50 +08:00
|
|
|
|
context=context,
|
2026-09-10 21:03:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
return _envelope(data, context)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 15:21:36 +08:00
|
|
|
|
# `_envelope` / `_list_envelope` 已抽到 `app/api/views/envelope.py`,与客服链路共用同一份
|
|
|
|
|
|
# §3.3 实现 —— 两处各写一份的结果就是其中一处漏了 `meta`。
|