Files
group_fqcd_jr/app/service/api_transaction_service.py
lzf_0626 15866d4564 风控写接口接入平台幂等(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 只调用一次处置逻辑"。
2026-09-11 14:12:56 +08:00

90 lines
4.5 KiB
Python

import hashlib
import json
from collections.abc import Awaitable, Callable
from typing import Any
from sqlalchemy import select
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import IdempotencyConflictError, ValidationAgentError
from app.infrastructure.db import SessionFactory
from app.repository.platform_repository import PlatformRepository
def digest(value: Any) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str,
ensure_ascii=False).encode()).hexdigest()
class ApiTransactionService:
async def execute(
self, context: RequestContext, scope: str, key: str | None, body: Any,
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
if key is None or not 16 <= len(key) <= 128 or not key.isascii():
raise ValidationAgentError("必须提供 16-128 位 ASCII Idempotency-Key")
async with SessionFactory() as session, session.begin():
table = await PlatformRepository(session).table("api_request_receipt")
statement = insert(table).values(user_id=int(context.user_id), scope_hash=digest(scope),
idempotency_key=key, request_hash=digest(body))
await session.execute(statement.on_duplicate_key_update(id=table.c.id))
row = (await session.execute(select(table).where(
table.c.user_id == int(context.user_id), table.c.scope_hash == digest(scope),
table.c.idempotency_key == key).with_for_update())).mappings().one()
if row["request_hash"] != digest(body):
raise IdempotencyConflictError("同一幂等键对应不同请求")
if row["response_json"] is not None:
return dict(row["response_json"])
response = await action(session)
await session.execute(table.update().where(table.c.id == row["id"])
.values(response_json=response))
return response
async def execute_in(
self,
session: AsyncSession,
context: RequestContext,
scope: str,
key: str | None,
body: Any,
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
"""`execute` 的"用调用方事务"版本,供 B 类业务写接口复用。
`execute` 自己开 `SessionFactory()` 和 `session.begin()`;但业务 Action Service
普遍在收尾时自己 `commit()`(例如 `RiskActionService._finish`),把它套进外层
`session.begin()` 就成了"内层提交外层事务"。这里改为在**调用方传入的 session**
上读写幂等记录,幂等记录因此与业务写入同处一个事务(`docs/05` §5.2)。
并发同键由 `SELECT … FOR UPDATE` 串行化:后到的请求要么直接回放
`response_json`,要么在拿到锁后看到同一请求哈希而继续执行。
"""
if key is None or not 16 <= len(key) <= 128 or not key.isascii():
raise ValidationAgentError("必须提供 16-128 位 ASCII Idempotency-Key")
table = await PlatformRepository(session).table("api_request_receipt")
statement = insert(table).values(
user_id=int(context.user_id),
scope_hash=digest(scope),
idempotency_key=key,
request_hash=digest(body),
)
await session.execute(statement.on_duplicate_key_update(id=table.c.id))
row = (await session.execute(select(table).where(
table.c.user_id == int(context.user_id),
table.c.scope_hash == digest(scope),
table.c.idempotency_key == key).with_for_update())).mappings().one()
if row["request_hash"] != digest(body):
raise IdempotencyConflictError("同一幂等键对应不同请求")
if row["response_json"] is not None:
return dict(row["response_json"])
response = await action(session)
await session.execute(table.update().where(table.c.id == row["id"])
.values(response_json=response))
# 业务 Action 多半已经提交了自己的写入(风控就是这样),所以这里必须再提交
# 一次才能把刚写回的 `response_json` 落库;若业务没有提交,则这一步同时提交
# 业务写入与幂等记录。
await session.commit()
return response