Files
hunjing/backend/app/services/idempotency.py
T
2026-08-21 16:00:13 +08:00

69 lines
2.5 KiB
Python

"""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