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/routers/account.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

166 lines
7.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 -*-
"""账号与全局歌单端点。
端点:
- POST/GET /api/v1/account/vip ↔ Flask /account/vip(admin/user 双路径 + 缓存)
- POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员)
- POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员)
"""
import hashlib
import time
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
import music_api
from core.state import VIP_CACHE, VIP_PROBE_LOCK, VIP_USER_CACHE
from music_api import APIException
from web import ratelimit
from web.deps import SiteUser, current_user, require_admin
from web.request_utils import (
request_data as _request_data,
)
from web.response import error, success
router = APIRouter()
AdminUser = Annotated[SiteUser, Depends(require_admin)]
_VIP_NAMES = {
0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP',
}
# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测
_VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK
def _vip_name(vip_type: int) -> str:
return _VIP_NAMES.get(vip_type, '普通用户')
def _cookies() -> dict[str, str]:
return music_api.load_cookies()
async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
"""检测账号会员类型与可解析音质。
单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s,
拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。
"""
import asyncio
api = music_api.NeteaseAPI()
acquired = await asyncio.to_thread(_VIP_PROCESS_LOCK.acquire, True, 0.5)
if not acquired:
info = await api.get_account_info(cookies)
if info:
return info.get('vipType', 0), 'account', []
return 0, 'probe', []
try:
probe = await api.detect_vip_by_probe(cookies)
levels_ok = probe.get('levels_ok', [])
info = await api.get_account_info(cookies)
if info:
return info.get('vipType', 0), 'account', levels_ok
return probe.get('vip_type', 0), 'probe', levels_ok
finally:
_VIP_PROCESS_LOCK.release()
@router.api_route('/account/vip', methods=['GET', 'POST'])
async def account_vip(
user: Annotated[SiteUser | None, Depends(current_user)],
request: Request,
):
"""当前解析账号的 VIP 类型(admin=全局账号 / user=个人账号)。无需登录。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
account = data.get('account', 'admin')
api = music_api.NeteaseAPI()
try:
if account == 'user':
from web.routers.music import _resolve_user_cookies
user_cookies = await _resolve_user_cookies(data, user, request)
if not user_cookies:
return success({'account': 'user', 'logged_in': False, 'vip_type': None,
'vip_name': '未登录', 'levels_ok': []}, '个人账号未登录')
ck = '|'.join(f'{k}={v}' for k, v in sorted(user_cookies.items()))
ck_hash = hashlib.md5(ck.encode('utf-8')).hexdigest()
cached_user = VIP_USER_CACHE.get(ck_hash)
if cached_user is not None and time.time() - cached_user['ts'] <= cached_user.get('ttl', 300):
return success(dict(cached_user['data'], account='user'), '获取个人账号信息成功(缓存)')
vip_type, method, levels_ok = await _detect_vip(user_cookies)
info = await api.get_account_info(user_cookies)
user_payload = {
'account': 'user', 'logged_in': True, 'vip_type': vip_type,
'vip_name': _vip_name(vip_type), 'detected_by': method, 'levels_ok': levels_ok,
'nickname': (info or {}).get('nickname', ''),
}
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s
ttl = 30 if not levels_ok else 300
if len(VIP_USER_CACHE) > 500:
VIP_USER_CACHE.clear()
VIP_USER_CACHE[ck_hash] = {'ts': time.time(), 'ttl': ttl, 'data': user_payload}
return success(user_payload, '获取个人账号信息成功')
cached = VIP_CACHE['data']
if cached is None or time.time() - VIP_CACHE['ts'] > VIP_CACHE.get('ttl', 300):
admin_cookies = _cookies()
vip_type, method, levels_ok = await _detect_vip(admin_cookies)
info = await api.get_account_info(admin_cookies)
cached = {
'vip_type': vip_type, 'vip_name': _vip_name(vip_type),
'detected_by': method, 'levels_ok': levels_ok,
'nickname': (info or {}).get('nickname', ''),
'logged_in': info is not None,
'note': '' if info else '账号接口未返回会员信息,已通过歌曲音质探测接口检测',
}
VIP_CACHE['data'] = cached
VIP_CACHE['ts'] = time.time()
VIP_CACHE['ttl'] = 30 if not levels_ok else 300
payload = dict(cached)
payload['account'] = 'admin'
payload['nickname'] = cached['nickname'] if (user and user.is_admin) else ''
return success(payload, '获取全局账号信息成功')
except Exception:
return error('服务暂时不可用,请稍后再试', 500)
@router.api_route('/global/playlists', methods=['GET', 'POST'])
async def global_playlists(_admin: AdminUser, request: Request):
"""全局账号收藏/创建的歌单(含「我喜欢的音乐」)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
api = music_api.NeteaseAPI()
cookies = _cookies()
try:
account = await api.get_account_info(cookies)
if not account or not account.get('userId'):
return error('全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录', 401)
playlists = await api.get_user_playlists(account['userId'], cookies)
return success({'user': account, 'playlists': playlists}, '获取全局账号歌单成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
@router.api_route('/global/playlist', methods=['GET', 'POST'])
async def global_playlist(_admin: AdminUser, request: Request):
"""全局账号歌单详情(全局 Cookie,支持「我喜欢的音乐」等私有歌单)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
from web.routers.music import _extract_id
data = await _request_data(request)
playlist_id = data.get('id')
if not playlist_id:
return error("参数 'playlist_id' 不能为空", 400)
playlist_id = _extract_id(playlist_id)
if not str(playlist_id).strip().isdigit():
return error('无效的歌单ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_playlist_detail(int(playlist_id), _cookies())
return success({'playlist': result}, '获取全局账号歌单详情成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)