风控写接口接入平台幂等(docs/25 P3 #22)
docs/05 §5.1 把"业务写接口"列为必须携带 Idempotency-Key 的接口,风控 6 个 POST (手工扫描、确认接收、进入调查、关闭误报、完成结案、升级处理)此前一个都没带, 重复提交会二次驱动状态机。 复用平台的 api_request_receipt 与 ApiTransactionService,但新增 execute_in:原来的 execute 自己开 SessionFactory() 和 session.begin(),而 RiskActionService._finish 会在内部 commit,套进去就成了"内层提交外层事务"。execute_in 改为在调用方传入的 session 上读写幂等记录,幂等记录因此与业务写入同处一个事务(§5.2)。 scope 用实际路径(含 alert_no)而不是路由模板:§5.1 的幂等范围是 user_id + method + normalized_path + idempotency_key,把路径参数折成模板会让同一个键 在不同预警之间互相回放 —— 那是把两次不同资源的操作当成一次。 测试:单测加幂等透传替身与"缺键即 422"用例;新增 tests/integration/test_risk_idempotency_mysql.py,覆盖同键回放不重复执行、同键不同 正文 409、缺键/非 ASCII 拒绝,以及端到端"重复 POST 只调用一次处置逻辑"。
This commit is contained in:
@@ -41,3 +41,49 @@ class ApiTransactionService:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user