风控写接口接入平台幂等(docs/25 P3 #22)

docs/05 §5.1 把"业务写接口"列为必须携带 Idempotency-Key 的接口,风控 6 个 POST
(手工扫描、确认接收、进入调查、关闭误报、完成结案、升级处理)此前一个都没带,
重复提交会二次驱动状态机。

复用平台的 api_request_receipt 与 ApiTransactionService,但新增 execute_in:原来的
execute 自己开 SessionFactory() 和 session.begin(),而 RiskActionService._finish
会在内部 commit,套进去就成了"内层提交外层事务"。execute_in 改为在调用方传入的
session 上读写幂等记录,幂等记录因此与业务写入同处一个事务(§5.2)。

scope 用实际路径(含 alert_no)而不是路由模板:§5.1 的幂等范围是
user_id + method + normalized_path + idempotency_key,把路径参数折成模板会让同一个键
在不同预警之间互相回放 —— 那是把两次不同资源的操作当成一次。

测试:单测加幂等透传替身与"缺键即 422"用例;新增
tests/integration/test_risk_idempotency_mysql.py,覆盖同键回放不重复执行、同键不同
正文 409、缺键/非 ASCII 拒绝,以及端到端"重复 POST 只调用一次处置逻辑"。
This commit is contained in:
2026-09-11 14:12:56 +08:00
parent 790518114b
commit 15866d4564
4 changed files with 360 additions and 19 deletions
+82 -15
View File
@@ -1,11 +1,11 @@
"""风控只读查询接口。"""
import json
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from datetime import datetime, time
from typing import Any
from fastapi import APIRouter, Depends, File, Path, Request, UploadFile
from fastapi import APIRouter, Depends, File, Header, Path, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
@@ -27,6 +27,7 @@ from app.api.schemas.risk import (
from app.core.contracts import RequestContext
from app.core.errors import SseNotAcceptableError
from app.infrastructure.db import mysql_scan_lock
from app.service.api_transaction_service import ApiTransactionService
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
@@ -42,6 +43,26 @@ router = APIRouter(
)
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)
@router.get("/overview")
async def risk_overview(
context: RequestContext = Depends(build_request_context), # noqa: B008
@@ -65,15 +86,21 @@ async def list_risk_alerts(
async def scan_risk_alerts(
context: RequestContext = Depends(build_request_context), # noqa: B008
session: AsyncSession = Depends(get_session), # noqa: B008
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> 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)
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("规则扫描正在执行,请稍后重试")
return await RiskScanService(inner).scan(context)
data = await _idempotent_write(
session, context, key, "POST /api/v1/risk/alerts/scan", {}, run
)
return _envelope(data, context)
@@ -82,8 +109,16 @@ 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
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
data = await RiskActionService(session).acknowledge(alert_no, context)
data = await _idempotent_write(
session,
context,
key,
f"POST /api/v1/risk/alerts/{alert_no}/acknowledgements",
{},
lambda inner: RiskActionService(inner).acknowledge(alert_no, context),
)
return _envelope(data, context)
@@ -92,8 +127,16 @@ 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
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
data = await RiskActionService(session).investigate(alert_no, context)
data = await _idempotent_write(
session,
context,
key,
f"POST /api/v1/risk/alerts/{alert_no}/investigations",
{},
lambda inner: RiskActionService(inner).investigate(alert_no, context),
)
return _envelope(data, context)
@@ -103,8 +146,16 @@ async def exclude_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
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
data = await RiskActionService(session).exclude(alert_no, payload.reason, context)
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),
)
return _envelope(data, context)
@@ -114,8 +165,16 @@ async def resolve_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
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
data = await RiskActionService(session).resolve(alert_no, payload.resolution, context)
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),
)
return _envelope(data, context)
@@ -125,8 +184,16 @@ async def escalate_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
key: str | None = Header(default=None, alias="Idempotency-Key"),
) -> dict[str, object]:
data = await RiskActionService(session).escalate(alert_no, payload.reason, context)
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),
)
return _envelope(data, context)