Files
group_fqcd_jr/app/api/controllers/risk.py
T
lzf_0626 e8b075bc7f fix(risk): 列表接口的信封对齐 docs/05 §3.3——游标从 data 挪到 meta
docs/05 §3.3 的列表样例是 data 为**纯数组**、
ext_cursor 与 has_more 放在 meta 里,
并明确「业务接口不得增加其他顶层字段」。而 RiskQueryService._page 返回的
{items, next_cursor, has_more} 被**整体塞进 data** —— 游标因此出现在**业务数据**里,
meta 只剩 trace_id,两处都不符合契约。

改动:
- 新增 _list_envelope(page, context):把 _page 的结构拆成 data = items、
  meta = {trace_id, next_cursor, has_more}。**service 侧不动** —— 它继续返回那个内部
  结构,只是不再直接当 data 用。
- 3 个列表端点改用它:/alerts、/evidence/{source}、/notifications。
  非列表端点(overview、详情、各类写操作)保持原样,不带游标。

**影响调用方**:这是接口形状变更。组员若写了前端读 data.items,需要改成读 data、
并从 meta 取分页元数据。改动依据是 docs/05 这个唯一权威接口文档(§20 也要求实现与
文档同步)。**合并时要提醒组员。**

**实测**(9002 身份):
- /alerts?limit=2 → 顶层键 ['data','meta']、data 是 list(2 条真实数据)、
  meta 键 ['has_more','next_cursor','trace_id']
- /evidence/customers 与 /notifications 同形状
- 对照 /overview(非列表)→ meta 只有 trace_id、不带游标

**测试**:3 处断言从 data["items"] 改为 data;/notifications 那处补上对 meta 的断言;
新增 test_list_endpoints_follow_the_documented_envelope,直接断言"data 是纯数组、
meta 恰好三个键、顶层恰好 data/meta",把 §3.3 的契约钉住。

过程里踩了两个自己的坑:① 忘了 rom typing import Any(与 customer_service.py 同一失误,
被 ruff/mypy 当场抓住);② 漏了 /evidence/{source} 这个列表端点,是测试先失败才发现的。

ruff / mypy(136 文件) / 640 unit+contract / 29 integration 全绿。
2026-09-11 14:01:16 +08:00

255 lines
10 KiB
Python

"""风控只读查询接口。"""
import json
from collections.abc import AsyncIterator
from datetime import datetime, time
from typing import Any
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 _list_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 _list_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 _list_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},
}
def _list_envelope(page: dict[str, Any], context: RequestContext) -> dict[str, object]:
"""列表资源的信封(docs/05 §3.3)。
§3.3 的列表样例是 `data` 为**纯数组**、游标与 `has_more` 放在 `meta` 里,并且明确
「业务接口不得增加其他顶层字段」。而 `RiskQueryService._page` 返回的是
`{items, next_cursor, has_more}` —— 整体塞进 `data` 后,游标跑进了**业务数据**里、
`meta` 只剩 trace_id,两处都不符合契约。
这里统一拆包;service 侧不必改(它继续返回那个内部结构,只是不再直接当 `data` 用)。
"""
return {
"data": page.get("items") or [],
"meta": {
"trace_id": context.trace_id,
"next_cursor": page.get("next_cursor"),
"has_more": bool(page.get("has_more")),
},
}