This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/web/billing.py
T
snowy-yang 6fb37534d3 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)。
2026-09-27 12:13:00 +08:00

128 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""计费依赖(账本预扣/退款),口径与登录闸一致。
历史实现用请求上下文(flask.g)暂存请求级账本键;本实现无 g,改用
「依赖返回值携带 ledger_key + 路由内显式退款」的方式,语义完全一致:
- 预扣与开账同事务:auth_system.reserve_and_open
- 失败退款:只退「仍 open 的账本行」(与装饰器 _refund_open 同口径,杜绝双退)
- 任务接手:auth_system.ledger_rename 把请求级行改挂 task_id(worker 结算唯一入口)
金额相关逻辑一律走 auth_system 既有函数,本模块不新造账务语义。
"""
import asyncio
import logging
import secrets
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
import auth_system
from web.deps import SiteUser
from web.errors import AppError
logger = logging.getLogger('web.billing')
@dataclass
class Billing:
"""一次请求的计费上下文。"""
user: SiteUser
ledger_key: str | None # 请求级账本键(unlimited 用户为 None)
need: int # 本次预扣次数(free 路径为 0)
free: bool
async def refund_open(self, reason: str = 'rejected') -> None:
"""仅对仍 open 的账本行全额退款(被 rename/结算过则跳过)。"""
key = self.ledger_key
if not key:
return
def _do() -> None:
try:
row = auth_system.ledger_get(key)
if row and row['status'] == 'open':
auth_system.ledger_settle(key, 0, reason)
except Exception:
pass
await asyncio.to_thread(_do)
async def settle(self, delivered: int, status: str = 'delivered') -> None:
"""交付结算(json 模式等无 task_id 的场景)。"""
key = self.ledger_key
if not key:
return
try:
await asyncio.to_thread(auth_system.ledger_settle, key, delivered, status)
except Exception as e:
logger.error('账本结算失败(重启对账兜底): %s %s', key, e)
async def rename_to(self, task_id: str) -> bool:
"""把请求级账本行改挂 task_id(任务接手账目)。无请求级行时开 reserved=0 的账。"""
if not self.ledger_key:
await asyncio.to_thread(auth_system.ledger_open, task_id, self.user.id, 0)
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,
*,
action: str = '',
free: bool = False,
cost: Callable[[], int] | None = None,
ip: str = '-',
) -> Billing:
"""登录 + 预扣闸(对应 Flask login_required)。
free=True:仅要求登录,不扣次数(解析/试听类)。
cost: 预扣次数计算(下载类:单曲 1 / 批量按去重后歌曲数)。
"""
if user is None:
raise AppError('请先登录后再使用解析功能', 401, error_code='unauthorized')
fresh = await asyncio.to_thread(auth_system.get_user_by_id, user.id)
if fresh is None:
raise AppError('登录已失效,请重新登录', 401, error_code='unauthorized')
credits = int(fresh['duration_seconds'] or 0)
unlimited = fresh['role'] == 'admin' or credits == -1
if free or unlimited:
return Billing(user=user, ledger_key=None, need=0, free=free)
need = 1
if cost is not None:
try:
need = max(1, int(cost()))
except Exception:
need = 1
key = 'req-' + secrets.token_hex(8)
remaining = await asyncio.to_thread(auth_system.reserve_and_open, fresh['id'], need, key)
if remaining is None:
await asyncio.to_thread(auth_system.add_log, 'fail', fresh['username'], ip,
action or '下载', f'剩余次数不足(本次需 {need} 次)')
raise AppError(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),'
'请充值卡密后继续下载', 401, error_code='expired')
return Billing(user=user, ledger_key=key, need=need, free=False)