From 6c7f8f589e1fecaf17190b5747dfda99eeb94f54 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:57:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20VIP=20=E5=8D=95=E9=A3=9E=E9=94=81?= =?UTF-8?q?=E6=94=B9=20asyncio.Lock=EF=BC=8C=E5=8E=BB=E6=8E=89=E6=AF=8F?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=B8=80=E6=AC=A1=E7=BA=BF=E7=A8=8B=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 失败)。 --- core/state.py | 4 +++- web/routers/account.py | 41 ++++++++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 12 deletions(-) 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()