"""HTTP 幂等键服务。 ``run_idempotent`` 自管 ``AsyncSessionLocal``:在同一 session 内查幂等记录、执行 handler、 写入结果(保证三者同事务)。``handler`` 接收该 session,供需要在同事务跑的业务(如发消息) 复用。调用方(路由)不持有 session。 """ import hashlib from collections.abc import Awaitable, Callable from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.errors import AppError, current_trace_id from app.db.session import AsyncSessionLocal from app.models.idempotency import IdempotencyKey from app.repositories import idempotency as idempotency_repo from app.repositories.json_utils import dumps, loads_dict def request_hash(payload: BaseModel) -> str: body = payload.model_dump_json(exclude_unset=True) return hashlib.sha256(body.encode("utf-8")).hexdigest() async def run_idempotent[T: BaseModel]( *, key: str | None, method: str, path: str, payload: BaseModel, response_type: type[T], resource_type: str, handler: Callable[[AsyncSession], Awaitable[T]], owner_scope: str = settings.default_owner_scope, ) -> T: """按 Idempotency-Key 保证请求只执行一次;handler 在本函数管理的 session 内执行。""" async with AsyncSessionLocal() as session: if not key: return await handler(session) scope = owner_scope digest = request_hash(payload) existing = await idempotency_repo.get_by_scope_key(session, scope=scope, key=key) if existing is not None: if existing.request_hash != digest: raise AppError("conflict", "Idempotency-Key 已用于不同请求。") return response_type.model_validate(loads_dict(existing.response_body_json)) response = await handler(session) resource_id = getattr(response, "run_id", None) or getattr(response, "id", None) record = IdempotencyKey( key=key, scope=scope, method=method, path=path, request_hash=digest, status="completed", response_status_code=200, response_body_json=dumps(response.model_dump(mode="json")), resource_type=resource_type, resource_id=str(resource_id) if resource_id else None, trace_id=current_trace_id(), ) session.add(record) await session.commit() return response