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 只调用一次处置逻辑"。
90 lines
4.5 KiB
Python
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
|