diff --git a/core/state.py b/core/state.py index fd9ce97..e4b549b 100644 --- a/core/state.py +++ b/core/state.py @@ -92,7 +92,9 @@ GLOBAL_MUSIC_U_CACHE: _TimedValue = {'v': None, 'ts': 0.0} # 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称 SERVER_NICK_CACHE: _NickCache = {'nick': None, 'ts': 0.0} SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游 -VIP_PROBE_LOCK = threading.Lock() # 音质探测单飞锁(有界等待) +# 注:音质探测的单飞锁(原 VIP_PROBE_LOCK)已移出本模块——本模块声明「依赖仅限 +# 标准库 threading/collections」,而那是把 asyncio 锁;且它全仓只有 +# web/routers/account.py 一个消费者(sync 时代遗留的「共享」注释已不成立)。 # 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, # 公网下账号栏高频刷新会连累服务器账号触发风控。 diff --git a/web/routers/account.py b/web/routers/account.py index 4e29a07..7a1f196 100644 --- a/web/routers/account.py +++ b/web/routers/account.py @@ -6,14 +6,15 @@ - 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, Any +from typing import Annotated from fastapi import APIRouter, Depends, Request import music_api -from core.state import VIP_CACHE, VIP_PROBE_LOCK, VIP_USER_CACHE +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 @@ -31,8 +32,25 @@ _VIP_NAMES = { 0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP', } -# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测 -_VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK +# 音质探测单飞锁: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: @@ -46,14 +64,15 @@ def _cookies() -> dict[str, str]: async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: """检测账号会员类型与可解析音质。 - 单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s, - 拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。 + 单飞锁为 asyncio.Lock:有界等待 0.5s,拿不到锁的请求直接用账号接口给 + 降级结果(不等 8 级探测)。 """ - import asyncio - api = music_api.NeteaseAPI() - acquired = await asyncio.to_thread(_VIP_PROCESS_LOCK.acquire, True, 0.5) - if not acquired: + 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', [] @@ -66,7 +85,7 @@ async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: return info.get('vipType', 0), 'account', levels_ok return probe.get('vip_type', 0), 'probe', levels_ok finally: - _VIP_PROCESS_LOCK.release() + lock.release()