fix(server): 修复路由遮蔽、流式背压与鉴权限流顺序

- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
This commit is contained in:
snowy-yang committed 2026-10-23 23:40:55 +08:00
1 parent 39565a3913
commit 0df18f048b
19 files changed
+534 -241

No files matched your search

-13
View File
@@ -25,7 +25,6 @@ __all__ = [
"find_music",
"format_play_count",
"format_play_time",
"init",
"search_music",
"size_formate",
"sources",
@@ -113,18 +112,6 @@ def _quality_table() -> dict[str, list[str]]:
support_quality: dict[str, list[str]] = _quality_table()
async def init() -> None:
"""调用各平台的 init 钩子(无 init 的跳过;单个失败不阻塞启动,与原 init 一致)。"""
for item in SUPPORTED_SOURCES:
hook = getattr(item["module"], "init", None)
if hook is None:
continue
try:
await hook()
except Exception as err: # noqa: BLE001
print(f"[sdk] {item['id']} init 失败(已跳过): {err}")
def _trim_str(value: Any) -> Any:
return value.strip() if isinstance(value, str) else value
+24 -20
View File
@@ -153,28 +153,32 @@ async def get_token(retry_num: int = 0) -> KwToken:
cached = _state["token"]
if cached is not None:
return cached
try:
cookies = await fetch_home_set_cookie()
except Exception as err:
if retry_num >= 2:
raise RuntimeError(f"获取酷我token失败: {err}") from err
return await get_token(retry_num + 1)
# 重试必须在锁内用循环完成:asyncio.Lock 不可重入,锁内递归会永久死锁。
last_error: Exception | None = None
for _attempt in range(max(0, retry_num), 3):
try:
cookies = await fetch_home_set_cookie()
except Exception as err: # noqa: BLE001 - 网络抖动重试,最终统一抛出
last_error = err
continue
found: list[tuple[str, str]] = []
for raw in cookies:
name, _, rest = raw.partition("=")
name = name.strip()
value = rest.split(";")[0] if rest else ""
if _TOKEN_NAME_RE.match(name) and value:
found.append((name, value))
if not found:
if retry_num >= 2:
raise RuntimeError("获取酷我token失败")
return await get_token(retry_num + 1)
found: list[tuple[str, str]] = []
for raw in cookies:
name, _, rest = raw.partition("=")
name = name.strip()
value = rest.split(";")[0] if rest else ""
if _TOKEN_NAME_RE.match(name) and value:
found.append((name, value))
if not found:
last_error = RuntimeError("响应中没有 Hm_Iuvt_* cookie")
continue
token = KwToken(found[0][0], found[0][1])
_state["token"] = token
return token
token = KwToken(found[0][0], found[0][1])
_state["token"] = token
return token
detail = f": {last_error}" if last_error else ""
raise RuntimeError(f"获取酷我token失败{detail}") from last_error
async def get_url_by_playurl(songmid: str, token: KwToken, retry_num: int = 0) -> dict[str, Any]:
+7 -4
View File
@@ -62,9 +62,9 @@ def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
return joined, match.group(1) if match else ""
async def _fetch_profile() -> dict[str, Any]:
"""登录成功后拉取账号资料。"""
body, _ = await _request_weapi("/api/w/nuser/account/get", {})
async def _fetch_profile(cookie: str | None = None) -> dict[str, Any]:
"""登录成功后拉取账号资料(cookie 显式传入新登录会话,避免首次登录读到旧 cookie)。"""
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
body = as_dict(body)
account = as_dict(body.get("account"))
profile = as_dict(body.get("profile"))
@@ -81,7 +81,10 @@ async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
cookie, music_u = _extract_login_cookie(set_cookie)
if not music_u:
raise RuntimeError("登录响应中缺少 MUSIC_U")
profile = await _fetch_profile()
try:
profile = await _fetch_profile(cookie)
except Exception: # noqa: BLE001 - 资料拉取失败不影响已成功的登录,返回空资料
profile = {"userid": None, "nickname": None, "avatar": None, "vipType": None}
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
return profile
+46 -24
View File
@@ -14,6 +14,7 @@ import os
import threading
import time
from datetime import UTC
from pathlib import Path
from typing import Any
from app.config import data_dir
@@ -21,41 +22,58 @@ from app.config import data_dir
_lock = threading.Lock()
_cache: dict[str, Any] = {}
_loaded = False
_cache_path: str | None = None
_cache_mtime: int | None = None
def _data_file():
def _data_file() -> Path:
return data_dir() / "auth.json"
def _load() -> dict[str, Any]:
global _cache, _loaded
if _loaded:
return _cache
path = _data_file()
if not path.exists():
_cache = {}
_loaded = True
return _cache
def _mtime(path: Path) -> int | None:
try:
_cache = json.loads(path.read_text(encoding="utf-8"))
return path.stat().st_mtime_ns
except OSError:
return None
def _backup_corrupt(path: Path, err: Exception) -> None:
"""损坏文件先备份再重置,避免后续 set_credentials 用空表覆盖可抢救的数据。"""
backup = path.with_name(f"{path.name}.corrupt-{int(time.time() * 1000)}")
try:
path.rename(backup)
print(f"[auth/store] {path} 解析失败,已备份为 {backup},本次以空凭据启动: {err}")
except OSError:
print(f"[auth/store] {path} 解析失败且备份未成功,本次以空凭据启动: {err}")
def _load() -> dict[str, Any]:
"""读取凭据缓存;文件被外部修改(mtime 变化)时自动重载。"""
global _cache, _loaded, _cache_path, _cache_mtime
path = _data_file()
mtime = _mtime(path)
if _loaded and _cache_path == str(path) and _cache_mtime == mtime:
return _cache
try:
if mtime is None:
_cache = {}
else:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("auth.json 顶层必须是 JSON 对象")
_cache = data
except (ValueError, OSError) as err:
# 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
# 否则下一次 set_credentials 就会用空表覆盖掉本可恢复的文件。
backup = path.with_name(f"{path.name}.corrupt-{int(time.time() * 1000)}")
try:
path.rename(backup)
print(f"[auth/store] {path} 解析失败,已备份为 {backup},本次以空凭据启动: {err}")
except OSError:
print(f"[auth/store] {path} 解析失败且备份未成功,本次以空凭据启动: {err}")
_backup_corrupt(path, err)
_cache = {}
_loaded = True
_cache_path = str(path)
_cache_mtime = _mtime(path)
return _cache
def _save(data: dict[str, Any]) -> None:
global _cache, _loaded
_cache = data
_loaded = True
global _cache, _loaded, _cache_path, _cache_mtime
path = _data_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
@@ -70,6 +88,10 @@ def _save(data: dict[str, Any]) -> None:
except OSError:
pass
raise
_cache = data
_loaded = True
_cache_path = str(path)
_cache_mtime = _mtime(path)
def get_credentials(platform: str) -> dict[str, Any] | None:
@@ -79,7 +101,7 @@ def get_credentials(platform: str) -> dict[str, Any] | None:
def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
with _lock:
data = _load()
data = dict(_load())
merged = {**data.get(platform, {}), **credentials, "updatedAt": _iso_now()}
data[platform] = merged
_save(data)
@@ -87,7 +109,7 @@ def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
def clear_credentials(platform: str) -> None:
with _lock:
data = _load()
data = dict(_load())
data.pop(platform, None)
_save(data)
-11
View File
@@ -23,7 +23,6 @@ from app.sdk.platforms.apple.util import (
extract_apple_id,
fetch_apple_song_resource,
get_apple_config,
get_apple_token,
)
@@ -31,15 +30,6 @@ def _song_id_of(song_info: dict[str, Any]) -> str:
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
async def _init() -> bool:
"""预热 developer token(失败不阻塞启动,路由调用时会重试)。"""
try:
await get_apple_token()
return True
except Exception: # noqa: BLE001 - token 预热失败不影响后续请求的重试
return False
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.apple import get_music_url
@@ -120,7 +110,6 @@ def status() -> dict[str, Any]:
MODULE = Platform(
id="apple",
name="Apple Music",
init=_init,
music_search=music_search,
album=_AppleAlbum,
playlist=playlist,
-7
View File
@@ -25,12 +25,6 @@ from app.sdk.platforms.kg import (
singer,
song_list,
)
from app.sdk.platforms.kg.util import get_device
async def _init() -> None:
"""预先固化设备指纹(取流签名依赖 dfid/mid,首次调用时生成并持久化)。"""
get_device()
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
@@ -61,5 +55,4 @@ MODULE = Platform(
get_pic=pic.get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
init=_init,
)