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