风控写接口接入平台幂等(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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user