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 9df0028195 feat(routes): 迁移下载/批量打包/播客下载(账本事务)+ 修密钥绑定缺陷
迁移端点:
- POST /api/v1/download(单曲:file 后台任务 / json 同步交付+结算)
- POST /api/v1/download/batch_zip(按去重歌曲数计费)
- POST /api/v1/podcast/download、/podcast/download/batch_zip
- 新增 web/billing.py(预扣+开账同事务 / 只退 open 行 / rename 承接)
  与 web/tasks_registry.py(任务登记/容量闸/账本撞名重试,与 main 同口径)
- worker 仍复用 main 既有实现(无 flask 依赖),账本与交付语义不变

修复我引入的真实缺陷:
- download 参数校验分支(ID/音质/格式非法)漏退款 → 预扣泄漏。改为统一 _fail() 退款
- web/routers/{auth,register}.py 在导入期绑定 deps.secret_key,导致 patch web.deps.secret_key
  无效、且测试顺序相关。改为调用期 deps.secret_key() 解析

测试:tests/test_download_billing.py 7 项沙箱用例如实断言账本守恒
(预扣必退、无 open 行残留、次数不足不建任务);
全套 pytest 69/69(顺序无关);ruff All checks passed;pyright 0 errors;奇偶 36/36。
2026-09-26 10:13:16 +08:00

108 lines
4.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 -*-
"""FastAPI 计费依赖(账本预扣/退款),与 Flask login_required 同口径。
Flask 版 login_required 用 flask.g 暂存请求级账本键;FastAPI 无 g,改用
「依赖返回值携带 ledger_key + 路由内显式退款」的方式,语义完全一致:
- 预扣与开账同事务:auth_system.reserve_and_open(A2/B8)
- 失败退款:只退「仍 open 的账本行」(与装饰器 _refund_open 同口径,杜绝双退)
- 任务接手:auth_system.ledger_rename 把请求级行改挂 task_id(worker 结算唯一入口)
金额相关逻辑一律走 auth_system 既有函数,本模块不新造账务语义。
"""
import asyncio
import logging
import secrets
from collections.abc import Callable
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)
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)