69 lines
2.5 KiB
Python
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
|