Archived
为什么:「任何失败路径必须退款」此前靠路由里每条早退/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)。
124 lines
4.2 KiB
Python
124 lines
4.2 KiB
Python
# -*- 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()
|