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 6288a98ecc refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。

拆分(对外导入路径不变,行为零变更):
- music_downloader.py  -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py       -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py         -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py  -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
  streaming/tasks);services/runtime.py 保留为兼容门面

兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。

修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py  ).strip() or '*'
- core/state.py   threading.Lock() x2
- qr_login.py     datetime.now() / .strip()
- music_downloader/downloader.py  .lower()
- services/podcast.py  set() / time.time() x2

清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
  失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败

验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
2026-09-26 12:37:48 +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 -*-
"""计费依赖(账本预扣/退款),口径与登录闸一致。
历史实现用请求上下文(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 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)