Files
group_fqcd_jr/app/service/api_transaction_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

90 lines
4.5 KiB
Python

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