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 全绿。
255 lines
10 KiB
Python
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")),
|
|
},
|
|
}
|