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
+160 -40

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""Billing.scope() 契约:作用域退出自动退还仍 open 的账本行。
设计前提(已由既有实现保证):refund_open 只退「仍 open」的行——
settle/rename_to 消费过的行天然不在退款范围,因此自动退与成功路径
互不干扰,调用方无需手动取消。测试直接对真实账本(沙箱库)断言。
"""
import pytest
import auth_system as auth
from web.billing import Billing
from web.deps import SiteUser
@pytest.fixture()
def ledger_env(tmp_path, monkeypatch):
monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path)
monkeypatch.setattr(auth.db, 'DB_PATH', tmp_path / 'site.db')
monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups')
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
auth.init_db()
row = auth.create_user('payer', 'Passw0rd12', email='p@example.com', credits=5)
assert row is not None, '建号应成功'
yield {'uid': int(row['id'])}
# 无需清理:tmp_path 由 pytest 回收
def _row(key: str):
"""取账本行,不存在则测试失败(供「行必须存在」的断言用)。"""
row = auth.ledger_get(key)
assert row is not None, f'账本行 {key} 应存在'
return row
def _user_credits() -> int:
u = auth.get_user_by_name('payer')
assert u is not None, '用户 payer 应存在'
return int(u['duration_seconds'] or 0)
def _make_billing(ledger_env, key: str | None) -> Billing:
user = SiteUser(id=ledger_env['uid'], username='payer', role='user', credits=5)
return Billing(user=user, ledger_key=key, need=1, free=False)
@pytest.mark.anyio
async def test_scope_auto_refunds_open_row(ledger_env):
"""正常退出作用域:仍 open 的行被退(rejected),余额恢复。"""
key = 'req-aaa'
reserved = auth.reserve_and_open(ledger_env['uid'], 1, key)
assert reserved is not None
assert _row(key)['status'] == 'open'
billing = _make_billing(ledger_env, key)
async with billing.scope():
pass # 业务体正常走完但未结算(等价于路由里的错误早退)
assert _row(key)['status'] == 'rejected'
assert _user_credits() == 5
@pytest.mark.anyio
async def test_scope_noop_after_settle(ledger_env):
"""作用域内已结算:退出自动退为 no-op,交付行不受影响。"""
key = 'req-bbb'
auth.reserve_and_open(ledger_env['uid'], 1, key)
billing = _make_billing(ledger_env, key)
async with billing.scope():
await billing.settle(1, 'delivered')
assert _row(key)['status'] == 'delivered'
assert _user_credits() == 4 # 恰好扣 1,未被二次退
@pytest.mark.anyio
async def test_scope_noop_after_rename(ledger_env):
"""作用域内账本已改挂 task_id:请求级行不存在,自动退为 no-op;
任务级行保持 open 交给 worker 结算——与迁移前手工语义逐路径一致。"""
key = 'req-ccc'
auth.reserve_and_open(ledger_env['uid'], 1, key)
billing = _make_billing(ledger_env, key)
async with billing.scope():
ok = await billing.rename_to('task-xyz')
assert ok is True
assert auth.ledger_get(key) is None
moved = _row('task-xyz')
assert moved['status'] == 'open'
@pytest.mark.anyio
async def test_scope_refunds_on_exception_and_reraises(ledger_env):
"""业务体抛异常:先退款,异常原样穿透(路由自行转错误响应)。"""
key = 'req-ddd'
auth.reserve_and_open(ledger_env['uid'], 1, key)
billing = _make_billing(ledger_env, key)
with pytest.raises(ValueError, match='boom'):
async with billing.scope():
raise ValueError('boom')
assert _row(key)['status'] == 'rejected'
@pytest.mark.anyio
async def test_scope_free_billing_noop(ledger_env):
"""free/unlimited(ledger_key=None):作用域进出零动作。"""
billing = _make_billing(ledger_env, None)
async with billing.scope():
pass
# 无账本行可动,不抛异常即为通过
assert _open_rows() == []
def _open_rows():
conn = auth.get_conn()
try:
rows = conn.execute("SELECT task_id FROM billing_ledger WHERE status='open'").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
+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,
+16 -39
View File
@@ -7,8 +7,9 @@
- POST /api/v1/podcast/download ↔ Flask /podcast/download(计费 1 次)
- POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip
计费:经 web.billing.require_billing(预扣+开账同事务 / 失败只退 open 行),
任务登记与启动由 web.tasks_registry 承担;
计费:经 web.billing.require_billing(预扣+开账同事务)+ billing.scope()
(「任何失败路径必须退款」不变量收敛在作用域退出:自动退仍 open 的行,
settle/rename 消费过的行天然豁免);任务登记与启动由 web.tasks_registry 承担;
worker 复用 services 层既有实现,账本与交付语义不变。
"""
import asyncio
@@ -67,14 +68,14 @@ async def _sync_download_json(downloader, music_id: str, quality: str, cookies:
billing, ip: str):
"""format=json 的同步下载分支:取信息 → 下载 → 组响应 → 结算。
语义:任一步失败退回预扣(账本守恒),只有成功才 settle(1, 'delivered')。
必须运行在 billing.scope() 内:失败退款由作用域退出统一完成,本函数
只负责在成功路径 settle(1, 'delivered')(结算后自动退自然豁免)。
"""
try:
info = await asyncio.to_thread(
downloader.get_music_info, int(music_id), quality, cookies)
except Exception as e:
logger.error('获取音乐信息失败: %s', e)
await billing.refund_open('rejected')
return error('获取失败:歌曲不存在或暂无版权', 404)
from services._shared import _build_download_base
base = await asyncio.to_thread(
@@ -88,11 +89,9 @@ async def _sync_download_json(downloader, music_id: str, quality: str, cookies:
cookies=cookies, music_info=info)
if not dl.success:
logger.error('下载失败: %s', dl.error_message)
await billing.refund_open('rejected')
return error('下载失败,请稍后重试或更换音质', 500)
except Exception as e:
logger.error('下载异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500)
try:
_record_log('下载', ip, info.name)
@@ -143,8 +142,8 @@ async def _resolve_personal(data: dict[str, Any], user: SiteUser | None, request
async def download(user: Annotated[SiteUser | None, Depends(current_user)], request: Request):
"""下载单曲(file=后台任务 / json=同步返回)。
顺序:限流 → 预扣计费 → 四道参数闸(失败即退款)→ 账号 Cookie →
可选参数 → json 同步分支 / file 后台任务分支。
顺序:限流 → 预扣计费 → billing.scope()(失败自动退款)→ 四道参数闸 →
账号 Cookie → 可选参数 → json 同步分支 / file 后台任务分支。
"""
await ratelimit.guard(request, bucket='dl', limit=20, window=60)
data = await _request_data(request)
@@ -155,28 +154,24 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
billing = await require_billing(user, action='音乐下载', ip=ip)
async def _fail(msg: str, code: int = 400):
"""参数/校验失败:退回本次预扣后返回错误(账本守恒)。"""
await billing.refund_open('rejected')
return error(msg, code)
async with billing.scope():
try:
if not music_id:
return await _fail("参数 'music_id' 不能为空", 400)
return error("参数 'music_id' 不能为空", 400)
music_id = str(music_id).strip()
if not music_id.isdigit():
return await _fail('无效的歌曲 ID')
return error('无效的歌曲 ID')
if quality not in VALID_QUALITY_LEVELS:
return await _fail(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
if return_format not in ('file', 'json'):
return await _fail("返回格式只支持 'file' 或 'json'")
return error("返回格式只支持 'file' 或 'json'")
music_id = _extract_id(music_id)
account = data.get('account', 'admin')
if account == 'user':
cookies = await _resolve_personal(data, user, request)
if not cookies:
return await _fail('请先扫码登录个人账号后再使用个人解析', 401)
return error('请先扫码登录个人账号后再使用个人解析', 401)
else:
cookies = _cookies()
@@ -204,7 +199,6 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
}, device_id)
reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429)
task_id = task['task_id']
start_err = await start_task(task_id, services_downloads._single_download_worker, (task_id,))
@@ -212,11 +206,9 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '下载任务已提交')
except APIException:
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500)
except Exception as e:
logger.error('下载音乐异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500)
@@ -250,23 +242,20 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
return max(1, min(len(_dedup_ids(data.get('ids') or data.get('id') or [])), 500))
billing = await require_billing(user, action='批量ZIP下载', cost=_cost, ip=ip)
async with billing.scope():
try:
raw_ids = data.get('ids') or data.get('id')
ids = _dedup_ids(raw_ids)
if not ids:
await billing.refund_open('rejected')
return error('缺少 ids 参数')
non_digit = [x for x in ids if not x.isdigit()]
if non_digit:
await billing.refund_open('rejected')
return error('ids 中含无效歌曲 ID')
if len(ids) > 500:
await billing.refund_open('rejected')
return error('单个批量任务最多 500 首,请分批提交')
quality = data.get('quality', 'lossless')
if quality not in VALID_QUALITY_LEVELS:
await billing.refund_open('rejected')
return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
filename_format = data.get('filename_format', 'artist_song')
write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes')
@@ -301,7 +290,6 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
}
reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429)
task_id = task['task_id']
start_err = await start_task(task_id, services_batch._batch_zip_worker, (task_id,))
@@ -311,7 +299,6 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
'批量打包任务已提交')
except Exception as e:
logger.error('批量打包提交异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500)
@@ -343,23 +330,21 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
ip = ratelimit.client_ip(request)
billing = await require_billing(user, action='播客单集下载', ip=ip)
async with billing.scope():
try:
if not raw:
await billing.refund_open('rejected')
return error("参数 'id' 不能为空", 400)
from music_api import podcast
quality = _podcast_quality(data)
need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes')
ref = podcast.extract_podcast_ref(raw)
if ref['kind'] == 'radio':
await billing.refund_open('rejected')
return error('这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」')
account = data.get('account', 'admin')
if account == 'user':
cookies = await _resolve_personal(data, user, request)
if not cookies:
await billing.refund_open('rejected')
return error('请先扫码登录个人账号后再使用个人解析', 401)
else:
cookies = _cookies()
@@ -379,7 +364,6 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
}
reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429)
task_id = task['task_id']
start_err = await start_task(task_id, services_podcast._podcast_download_worker, (task_id,))
@@ -387,11 +371,9 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '播客单集下载任务已提交')
except APIException as e:
await billing.refund_open('rejected')
return error(str(e), 404)
except Exception as e:
logger.error('播客单集下载提交异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500)
@@ -406,11 +388,11 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
return _podcast_billing_count(data)
billing = await require_billing(user, action='播客整台打包', cost=_cost, ip=ip)
async with billing.scope():
try:
raw_radio = data.get('radio_id')
raw_ids = data.get('ids')
if not raw_radio and not raw_ids:
await billing.refund_open('rejected')
return error('缺少 radio_id 或 ids 参数')
limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500))
quality = _podcast_quality(data)
@@ -423,7 +405,6 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
if raw_radio:
ref = podcast.extract_podcast_ref(raw_radio)
if ref['kind'] == 'program':
await billing.refund_open('rejected')
return error('这是单集链接,请使用「下载单集」或直接输入电台链接/ID')
radio_id = ref['id']
radio_name = ''
@@ -434,7 +415,6 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
else:
id_list = _dedup_ids(raw_ids)[:limit]
if not id_list:
await billing.refund_open('rejected')
return error('缺少 ids 参数')
radio_id = None
radio_name = str(data.get('name') or '播客单集')
@@ -462,7 +442,6 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
}
reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429)
task_id = task['task_id']
start_err = await start_task(task_id, services_podcast._podcast_zip_worker, (task_id,))
@@ -470,9 +449,7 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
return error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交')
except APIException as e:
await billing.refund_open('rejected')
return error(str(e), 404)
except Exception as e:
logger.error('播客打包提交异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500)