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 6c7f8f589e refactor: VIP 单飞锁改 asyncio.Lock,去掉每请求一次线程跳转
web/routers/account.py 的音质探测单飞锁原本是 core.state 里的 threading.Lock,
在协程里用 `await asyncio.to_thread(lock.acquire, True, 0.5)` 做有界等待,每次
VIP 请求都白付一次线程池调度。

为什么可以直接换:这把锁**只在协程里用**。原注释写「与 core.state 中同一把锁
共享」,但 grep 全仓只有 _detect_vip 一个消费者(sync 时代另有一个线程侧使用者,
早已不存在)。改为 asyncio.Lock + asyncio.wait_for(acquire(), 0.5),超时分支用
`except TimeoutError` 走「不等 8 级探测、直接用账号接口降级」的原有语义。

顺带把锁从 core.state 移出:core.state 自己声明「依赖仅限标准库
threading/collections」,而这把锁是 asyncio 的。现在它跟着唯一消费者走。

锁按事件循环隔离(以 id(loop) 为键):asyncio.Lock 绑定创建它的 loop,应用主 loop
与测试各自的 loop 并存,跨 loop 复用会抛 "bound to a different event loop"
(B2 迁移 auth_system 时踩过同一个坑)。

门禁:ruff 全过 / pyright 0-0-0 / pytest 382 项全绿(0 失败)。
2026-09-29 14:57:24 +08:00

185 lines
8.0 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 asyncio
import hashlib
import time
from typing import Annotated
from fastapi import APIRouter, Depends, Request
import music_api
from core.state import VIP_CACHE, 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',
}
# 音质探测单飞锁:probe 一次要打 8 次网易云接口,并发请求各自串行探测会连累
# 服务器账号触发风控,故同一时刻只放一个请求真探测,其余直接拿账号接口的降级结果。
#
# 用 asyncio.Lock:本模块是这把锁**唯一**的消费者(原注释「与 core.state 中同一把
# 锁共享」是 sync 时代残留,当时另有一个线程侧使用者,现已不存在),故从
# core.state 移到这里,也让 core.state 保持「只依赖 threading/collections」。
# 按事件循环隔离:asyncio.Lock 绑定创建它的 loop,应用主 loop 与测试各自的 loop
# 并存,跨 loop 复用会抛 "bound to a different event loop"。
_PROBE_LOCKS: dict[int, asyncio.Lock] = {}
def _probe_lock() -> asyncio.Lock:
"""当前事件循环的探测单飞锁。"""
key = id(asyncio.get_running_loop())
lock = _PROBE_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_PROBE_LOCKS[key] = lock
return 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]]:
"""检测账号会员类型与可解析音质。
单飞锁为 asyncio.Lock:有界等待 0.5s,拿不到锁的请求直接用账号接口给
降级结果(不等 8 级探测)。
"""
api = music_api.NeteaseAPI()
lock = _probe_lock()
try:
await asyncio.wait_for(lock.acquire(), 0.5)
except TimeoutError:
# 已有请求在探测:不等它(等 8 级探测会拖长响应),直接给降级结果
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:
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)