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 asyncio
import logging import logging
import secrets import secrets
from collections.abc import Callable from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
import auth_system import auth_system
@@ -63,6 +64,25 @@ class Billing:
return True return True
return await asyncio.to_thread(auth_system.ledger_rename, self.ledger_key, task_id) 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( async def require_billing(
user: SiteUser | None, 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 ↔ Flask /podcast/download(计费 1 次)
- POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip - POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip
计费:经 web.billing.require_billing(预扣+开账同事务 / 失败只退 open 行), 计费:经 web.billing.require_billing(预扣+开账同事务)+ billing.scope()
任务登记与启动由 web.tasks_registry 承担; (「任何失败路径必须退款」不变量收敛在作用域退出:自动退仍 open 的行,
settle/rename 消费过的行天然豁免);任务登记与启动由 web.tasks_registry 承担;
worker 复用 services 层既有实现,账本与交付语义不变。 worker 复用 services 层既有实现,账本与交付语义不变。
""" """
import asyncio import asyncio
@@ -67,14 +68,14 @@ async def _sync_download_json(downloader, music_id: str, quality: str, cookies:
billing, ip: str): billing, ip: str):
"""format=json 的同步下载分支:取信息 → 下载 → 组响应 → 结算。 """format=json 的同步下载分支:取信息 → 下载 → 组响应 → 结算。
语义:任一步失败退回预扣(账本守恒),只有成功才 settle(1, 'delivered')。 必须运行在 billing.scope() 内:失败退款由作用域退出统一完成,本函数
只负责在成功路径 settle(1, 'delivered')(结算后自动退自然豁免)。
""" """
try: try:
info = await asyncio.to_thread( info = await asyncio.to_thread(
downloader.get_music_info, int(music_id), quality, cookies) downloader.get_music_info, int(music_id), quality, cookies)
except Exception as e: except Exception as e:
logger.error('获取音乐信息失败: %s', e) logger.error('获取音乐信息失败: %s', e)
await billing.refund_open('rejected')
return error('获取失败:歌曲不存在或暂无版权', 404) return error('获取失败:歌曲不存在或暂无版权', 404)
from services._shared import _build_download_base from services._shared import _build_download_base
base = await asyncio.to_thread( 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) cookies=cookies, music_info=info)
if not dl.success: if not dl.success:
logger.error('下载失败: %s', dl.error_message) logger.error('下载失败: %s', dl.error_message)
await billing.refund_open('rejected')
return error('下载失败,请稍后重试或更换音质', 500) return error('下载失败,请稍后重试或更换音质', 500)
except Exception as e: except Exception as e:
logger.error('下载异常: %s', e) logger.error('下载异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500) return error('服务暂时不可用,请稍后再试', 500)
try: try:
_record_log('下载', ip, info.name) _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): async def download(user: Annotated[SiteUser | None, Depends(current_user)], request: Request):
"""下载单曲(file=后台任务 / json=同步返回)。 """下载单曲(file=后台任务 / json=同步返回)。
顺序:限流 → 预扣计费 → 四道参数闸(失败即退款)→ 账号 Cookie → 顺序:限流 → 预扣计费 → billing.scope()(失败自动退款)→ 四道参数闸 →
可选参数 → json 同步分支 / file 后台任务分支。 账号 Cookie → 可选参数 → json 同步分支 / file 后台任务分支。
""" """
await ratelimit.guard(request, bucket='dl', limit=20, window=60) await ratelimit.guard(request, bucket='dl', limit=20, window=60)
data = await _request_data(request) 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) billing = await require_billing(user, action='音乐下载', ip=ip)
async def _fail(msg: str, code: int = 400): async with billing.scope():
"""参数/校验失败:退回本次预扣后返回错误(账本守恒)。"""
await billing.refund_open('rejected')
return error(msg, code)
try: try:
if not music_id: if not music_id:
return await _fail("参数 'music_id' 不能为空", 400) return error("参数 'music_id' 不能为空", 400)
music_id = str(music_id).strip() music_id = str(music_id).strip()
if not music_id.isdigit(): if not music_id.isdigit():
return await _fail('无效的歌曲 ID') return error('无效的歌曲 ID')
if quality not in VALID_QUALITY_LEVELS: 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'): if return_format not in ('file', 'json'):
return await _fail("返回格式只支持 'file' 或 'json'") return error("返回格式只支持 'file' 或 'json'")
music_id = _extract_id(music_id) music_id = _extract_id(music_id)
account = data.get('account', 'admin') account = data.get('account', 'admin')
if account == 'user': if account == 'user':
cookies = await _resolve_personal(data, user, request) cookies = await _resolve_personal(data, user, request)
if not cookies: if not cookies:
return await _fail('请先扫码登录个人账号后再使用个人解析', 401) return error('请先扫码登录个人账号后再使用个人解析', 401)
else: else:
cookies = _cookies() cookies = _cookies()
@@ -204,7 +199,6 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
}, device_id) }, device_id)
reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip()) reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err: if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] task_id = task['task_id']
start_err = await start_task(task_id, services_downloads._single_download_worker, (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 error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '下载任务已提交') return success({'task_id': task_id, 'status': 'queued'}, '下载任务已提交')
except APIException: except APIException:
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500) return error('服务暂时不可用,请稍后再试', 500)
except Exception as e: except Exception as e:
logger.error('下载音乐异常: %s', e) logger.error('下载音乐异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500) 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)) 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) billing = await require_billing(user, action='批量ZIP下载', cost=_cost, ip=ip)
async with billing.scope():
try: try:
raw_ids = data.get('ids') or data.get('id') raw_ids = data.get('ids') or data.get('id')
ids = _dedup_ids(raw_ids) ids = _dedup_ids(raw_ids)
if not ids: if not ids:
await billing.refund_open('rejected')
return error('缺少 ids 参数') return error('缺少 ids 参数')
non_digit = [x for x in ids if not x.isdigit()] non_digit = [x for x in ids if not x.isdigit()]
if non_digit: if non_digit:
await billing.refund_open('rejected')
return error('ids 中含无效歌曲 ID') return error('ids 中含无效歌曲 ID')
if len(ids) > 500: if len(ids) > 500:
await billing.refund_open('rejected')
return error('单个批量任务最多 500 首,请分批提交') return error('单个批量任务最多 500 首,请分批提交')
quality = data.get('quality', 'lossless') quality = data.get('quality', 'lossless')
if quality not in VALID_QUALITY_LEVELS: if quality not in VALID_QUALITY_LEVELS:
await billing.refund_open('rejected')
return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}") return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
filename_format = data.get('filename_format', 'artist_song') filename_format = data.get('filename_format', 'artist_song')
write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') 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()) reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err: if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] task_id = task['task_id']
start_err = await start_task(task_id, services_batch._batch_zip_worker, (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: except Exception as e:
logger.error('批量打包提交异常: %s', e) logger.error('批量打包提交异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500) return error('服务暂时不可用,请稍后再试', 500)
@@ -343,23 +330,21 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
ip = ratelimit.client_ip(request) ip = ratelimit.client_ip(request)
billing = await require_billing(user, action='播客单集下载', ip=ip) billing = await require_billing(user, action='播客单集下载', ip=ip)
async with billing.scope():
try: try:
if not raw: if not raw:
await billing.refund_open('rejected')
return error("参数 'id' 不能为空", 400) return error("参数 'id' 不能为空", 400)
from music_api import podcast from music_api import podcast
quality = _podcast_quality(data) quality = _podcast_quality(data)
need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes')
ref = podcast.extract_podcast_ref(raw) ref = podcast.extract_podcast_ref(raw)
if ref['kind'] == 'radio': if ref['kind'] == 'radio':
await billing.refund_open('rejected')
return error('这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」') return error('这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」')
account = data.get('account', 'admin') account = data.get('account', 'admin')
if account == 'user': if account == 'user':
cookies = await _resolve_personal(data, user, request) cookies = await _resolve_personal(data, user, request)
if not cookies: if not cookies:
await billing.refund_open('rejected')
return error('请先扫码登录个人账号后再使用个人解析', 401) return error('请先扫码登录个人账号后再使用个人解析', 401)
else: else:
cookies = _cookies() 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()) reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err: if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] task_id = task['task_id']
start_err = await start_task(task_id, services_podcast._podcast_download_worker, (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 error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '播客单集下载任务已提交') return success({'task_id': task_id, 'status': 'queued'}, '播客单集下载任务已提交')
except APIException as e: except APIException as e:
await billing.refund_open('rejected')
return error(str(e), 404) return error(str(e), 404)
except Exception as e: except Exception as e:
logger.error('播客单集下载提交异常: %s', e) logger.error('播客单集下载提交异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500) return error('服务暂时不可用,请稍后再试', 500)
@@ -406,11 +388,11 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
return _podcast_billing_count(data) return _podcast_billing_count(data)
billing = await require_billing(user, action='播客整台打包', cost=_cost, ip=ip) billing = await require_billing(user, action='播客整台打包', cost=_cost, ip=ip)
async with billing.scope():
try: try:
raw_radio = data.get('radio_id') raw_radio = data.get('radio_id')
raw_ids = data.get('ids') raw_ids = data.get('ids')
if not raw_radio and not raw_ids: if not raw_radio and not raw_ids:
await billing.refund_open('rejected')
return error('缺少 radio_id 或 ids 参数') return error('缺少 radio_id 或 ids 参数')
limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500)) limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500))
quality = _podcast_quality(data) quality = _podcast_quality(data)
@@ -423,7 +405,6 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
if raw_radio: if raw_radio:
ref = podcast.extract_podcast_ref(raw_radio) ref = podcast.extract_podcast_ref(raw_radio)
if ref['kind'] == 'program': if ref['kind'] == 'program':
await billing.refund_open('rejected')
return error('这是单集链接,请使用「下载单集」或直接输入电台链接/ID') return error('这是单集链接,请使用「下载单集」或直接输入电台链接/ID')
radio_id = ref['id'] radio_id = ref['id']
radio_name = '' radio_name = ''
@@ -434,7 +415,6 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
else: else:
id_list = _dedup_ids(raw_ids)[:limit] id_list = _dedup_ids(raw_ids)[:limit]
if not id_list: if not id_list:
await billing.refund_open('rejected')
return error('缺少 ids 参数') return error('缺少 ids 参数')
radio_id = None radio_id = None
radio_name = str(data.get('name') or '播客单集') 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()) reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip())
if reg_err: if reg_err:
await billing.refund_open('rejected')
return error(reg_err, 429) return error(reg_err, 429)
task_id = task['task_id'] task_id = task['task_id']
start_err = await start_task(task_id, services_podcast._podcast_zip_worker, (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 error(start_err, 500)
return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交') return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交')
except APIException as e: except APIException as e:
await billing.refund_open('rejected')
return error(str(e), 404) return error(str(e), 404)
except Exception as e: except Exception as e:
logger.error('播客打包提交异常: %s', e) logger.error('播客打包提交异常: %s', e)
await billing.refund_open('rejected')
return error('服务暂时不可用,请稍后再试', 500) return error('服务暂时不可用,请稍后再试', 500)