Files
group_fqcd_jr/app/service/api_transaction_service.py
T

90 lines
4.5 KiB
Python
Raw Normal View History

2026-09-09 21:55:37 +08:00
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