风控写接口接入平台幂等(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
+54 -4
View File
@@ -8,6 +8,8 @@ from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.main import create_app
IDEMPOTENCY = {"Idempotency-Key": "risk-controller-test-0001"}
class StubRiskQueryService:
def __init__(self, _session: Any) -> None:
@@ -31,6 +33,25 @@ class StubRiskQueryService:
return {"items": [{"source": source}], "next_cursor": None, "has_more": False}
class StubApiTransactionService:
"""幂等透传替身:直接执行 action,不写 `api_request_receipt`。
单测不连库,幂等读写的真实语义由 `tests/integration` 覆盖;这里只保证请求体与
状态码断言不被数据库依赖污染。
"""
async def execute_in(
self,
session: Any,
_context: RequestContext,
_scope: str,
_key: str | None,
_body: Any,
action: Any,
) -> dict[str, Any]:
return await action(session)
class StubRiskScanService:
def __init__(self, _session: Any) -> None:
pass
@@ -120,7 +141,7 @@ class StubRiskDailyReportMailService:
return {"status": "dry_run", "recipient_count": len(recipients)}
def authenticated_client(monkeypatch) -> TestClient:
def authenticated_client(monkeypatch, *, idempotent: bool = True) -> TestClient:
async def context() -> RequestContext:
return RequestContext(
user_id="990000002",
@@ -145,6 +166,12 @@ def authenticated_client(monkeypatch) -> TestClient:
"RiskNotificationService",
StubRiskNotificationService,
)
if idempotent:
monkeypatch.setattr(
risk_controller,
"ApiTransactionService",
StubApiTransactionService,
)
monkeypatch.setattr(
risk_controller,
"RiskDailyReportService",
@@ -210,7 +237,7 @@ def test_evidence_route_and_page_limit_are_enforced(monkeypatch) -> None:
def test_scan_route_returns_success_envelope(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
response = client.post("/api/v1/risk/alerts/scan")
response = client.post("/api/v1/risk/alerts/scan", headers=IDEMPOTENCY)
assert response.status_code == 200
assert response.json() == {
@@ -232,23 +259,31 @@ def test_action_routes_require_authentication() -> None:
def test_action_routes_use_success_envelope_and_validate_body(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
acknowledged = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements")
investigated = client.post("/api/v1/risk/alerts/ALERT-001/investigations")
acknowledged = client.post(
"/api/v1/risk/alerts/ALERT-001/acknowledgements", headers=IDEMPOTENCY
)
investigated = client.post(
"/api/v1/risk/alerts/ALERT-001/investigations", headers=IDEMPOTENCY
)
excluded = client.post(
"/api/v1/risk/alerts/ALERT-001/exclusions",
json={"reason": "客户本人确认"},
headers=IDEMPOTENCY,
)
resolved = client.post(
"/api/v1/risk/alerts/ALERT-001/resolutions",
json={"resolution": "已核实并留痕"},
headers=IDEMPOTENCY,
)
escalated = client.post(
"/api/v1/risk/alerts/ALERT-001/escalations",
json={"reason": "需要高级复核"},
headers=IDEMPOTENCY,
)
invalid = client.post(
"/api/v1/risk/alerts/ALERT-001/exclusions",
json={"reason": " "},
headers=IDEMPOTENCY,
)
assert acknowledged.status_code == 200
@@ -259,6 +294,21 @@ def test_action_routes_use_success_envelope_and_validate_body(monkeypatch) -> No
assert invalid.status_code == 422
def test_write_routes_require_idempotency_key(monkeypatch) -> None:
"""docs/05 §5.1:业务写接口必须携带 `Idempotency-Key`,缺失或过短都是 422。"""
with authenticated_client(monkeypatch, idempotent=False) as client:
missing = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements")
too_short = client.post(
"/api/v1/risk/alerts/ALERT-001/acknowledgements",
headers={"Idempotency-Key": "too-short"},
)
assert missing.status_code == 422
assert missing.json()["error"]["code"] == "AGENT_INPUT_INVALID"
assert too_short.status_code == 422
assert too_short.json()["error"]["code"] == "AGENT_INPUT_INVALID"
def test_evidence_upload_uses_success_envelope(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
response = client.post(