refactor: 计费退款不变量收敛为 Billing.scope()(download.py 25 处手工退款 → 0)

为什么:「任何失败路径必须退款」此前靠路由里每条早退/except 手工调
refund_open 维持(download.py 单文件 25 处),上一轮拆 register/login
时已经体会过这个模式的代价——改任何一条返回路径都要核对还原时序,
漏一处不会有测试报警,只剩重启对账兜底在跑。

改动:
- Billing.scope()(asynccontextmanager):作用域退出时自动退还仍 open
  的账本行。正确性完全依赖 refund_open 的既有幂等口径(只退仍 open
  的行)——settle()/rename_to() 消费过的行天然豁免,成功路径零动作,
  无需手动「取消自动退」;未捕获异常先退款再原样穿透。
- download.py 四个端点全部改用 async with billing.scope(),删除全部
  手工退款与 _fail 收尾闭包;_sync_download_json 的内部退款一并移除
  (docstring 标明必须在 scope 内运行)。
- 语义逐路径等价:错误早退→自动退(同原手工);register 成功后
  rename→自动退 no-op(同原手工退的 no-op);start 失败行挂 task_id
  留给对账(同原);CancelledError 语义不变(退款随取消中断,对账兜底)。

先写测试后动代码:tests/test_billing_scope.py 5 例(TDD 红→绿),
对真实沙箱账本断言——open 行自动退、settle 后 no-op 且余额恰 -1、
rename 后请求级行消失且任务级行保持 open、异常穿透、free 路径零动作。
既有 30 例计费/下载端到端测试作迁移回归网。
门禁:ruff 全过 · pyright 0/0 · pytest 387 passed(382→387)。
This commit is contained in:
snowy-yang committed 2026-09-27 12:13:00 +08:00
1 parent 9a34517454
commit 6fb37534d3
3 files changed
+364 -244

No files matched your search

+21 -1
View File
@@ -12,7 +12,8 @@
import asyncio
import logging
import secrets
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
import auth_system
@@ -63,6 +64,25 @@ class Billing:
return True
return await asyncio.to_thread(auth_system.ledger_rename, self.ledger_key, task_id)
@asynccontextmanager
async def scope(self) -> AsyncGenerator['Billing', None]:
"""计费作用域:退出时自动退还仍 open 的账本行。
把「任何失败路径必须退款」这条不变量从路由的每条早退/except 里
收敛到这里。正确性依赖 refund_open 的既有幂等口径(只退仍 open
的行),因此:
- 错误早退 / 异常转错误响应 → 正常退出作用域 → 自动退;
- settle()/rename_to() 已消费的行不在退款范围,成功路径自然豁免,
无需手动「取消自动退」;
- 未捕获异常先退款再原样穿透,由路由既有 except 转错误响应;
- 客户端断连(CancelledError)与迁移前行为一致:退款可能被一并
取消,open 行留给重启对账兜底,不额外加 shield 复杂化。
"""
try:
yield self
finally:
await self.refund_open('rejected')
async def require_billing(
user: SiteUser | None,