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 cd0f1f78a3 refactor: 迁移收尾(管理员2FA)+ 抽取公共工具层,消除 router 间重复
补齐最后缺口:
- POST /api/v1/auth/admin_login/email、/auth/admin_login/verify(管理员 2FA Step2/3),
  复用 auth_routes 的票据表与尝试上限(跨栈共享),签发会话 Cookie

重构(消除约 200 行重复,11 个 router 受益):
- web/request_utils.py:request_data / safe_int / extract_id / cookies / dedup_ids
  原在各 router 各复制一份,现集中
- web/serializers.py:format_credits / user_public / record_req_log
- 各 router 改为导入别名,行为逐字不变

覆盖率:Flask 90 条业务路由全部有 FastAPI 等价端点(含 5 个向后兼容别名)。
验证:pytest 69/69;ruff All checks passed;pyright 0 errors;奇偶 36/36;冒烟 10/10。
2026-09-26 10:20:46 +08:00

166 lines
7.2 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 -*-
"""账号与全局歌单端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
已迁移:
- 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',
}
# 探测单飞:与 Flask _VIP_PROBE_LOCK 同源(跨栈共享,避免两栈各自串行 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]]:
"""检测账号会员类型与可解析音质(与 Flask _detect_vip 同口径)。
单飞锁为 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(与 Flask 同口径)
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)