Archived
feat(routes): 迁移查询类路由到 FastAPI + 会话互通 + 统一错误/限流
会话互通(迁移关键): - core/session.py:itsdangerous 兼容的签名 Cookie 编解码(同密钥/同 salt/同 时间戳编码),Flask 与 FastAPI 双向可读——实测 F->core 与 core->F 双向解出一致, 篡改/错密钥返回 None FastAPI 基础设施: - web/deps.py 会话→当前用户(封禁/顶号校验,口径同 auth_system.current_user) - web/errors.py AppError + 全局异常处理,404/405/400 统一为本站包体 - web/ratelimit.py 复用 core.state 同一批限流桶(跨栈共享,避免绕框架绕过限流) - core/repo.py 补 get_user_by_id(异步) 迁移路由(/api/v1/*,与 Flask 同端点逐字段对齐): - toplist / artist_songs / comments / search,走 music_api 异步方法 - search 的服务器昵称遮蔽改为 FastAPI 侧异步实现(不再依赖 main) 契约奇偶校验:扩展至 7 项(含登录会话互通 + 未登录 401),全 OK; CDN 分片主机差异归一化(上游易变值,非契约漂移)。 验证:pytest 33/33;ruff All checks passed;pyright 0 errors; uvicorn 实跑未登录 401 包体与 Flask 一致。
This commit is contained in:
1 parent
c77ef40fd5
commit
2c55b55e03
9 files changed
+559
-6
No files matched your search
@@ -24,11 +24,5 @@ _css_ok.txt
|
|||||||
_audit_work/
|
_audit_work/
|
||||||
.zcode/
|
.zcode/
|
||||||
.env
|
.env
|
||||||
新会话引导.md
|
|
||||||
交接文档.md
|
|
||||||
修复验收台账_F01-F117.md
|
|
||||||
待修问题清单_三轮汇总.md
|
|
||||||
待修问题清单_第四轮深挖.md
|
|
||||||
待修问题清单_第五轮深挖.md
|
|
||||||
_smoke_test_downloader.py
|
_smoke_test_downloader.py
|
||||||
云村小助手-网站开关.bat
|
云村小助手-网站开关.bat
|
||||||
@@ -54,6 +54,11 @@ async def get_user_by_name(username: str) -> dict[str, Any] | None:
|
|||||||
return dict(row) if row is not None else None
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(uid: int) -> dict[str, Any] | None:
|
||||||
|
row = await db.fetch_one('SELECT * FROM users WHERE id = ?', (uid,))
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
# ---------- billing ledger(只读对账) ----------
|
# ---------- billing ledger(只读对账) ----------
|
||||||
|
|
||||||
async def ledger_open_count() -> int:
|
async def ledger_open_count() -> int:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Flask 会话 Cookie 与 Starlette/FastAPI 的互操作。
|
||||||
|
|
||||||
|
Flask 默认会话即 itsdangerous 签名 Cookie(`session` cookie,格式 `payload.timestamp.hmac`,
|
||||||
|
payload 为 compact JSON、值类型化 `{"site_uid":{" b":i"4":".."}}`)。迁移期要让
|
||||||
|
「Flask 签发的会话在 FastAPI 侧可读、FastAPI 刷新后可被 Flask 读回」,
|
||||||
|
本模块给出同一套编解码,避免两栈各签一份、互相看不懂。
|
||||||
|
|
||||||
|
密钥同源:都取自 auth_system.get_secret_key()(data/secret.key)。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# 与 Flask 默认一致:SHA1 + itsdangerous 的 key derivation(HMAC(digest, salt))
|
||||||
|
_SALT = 'cookie-session'
|
||||||
|
_DIGEST = hashlib.sha1
|
||||||
|
|
||||||
|
SESSION_KEY = 'site_uid'
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_key(secret: str, salt: str = _SALT) -> bytes:
|
||||||
|
"""itsdangerous 的密钥派生:HMAC(secret, salt)。"""
|
||||||
|
mac = hmac.new(secret.encode('utf-8'), salt.encode('utf-8'), _DIGEST)
|
||||||
|
return mac.digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _int_to_bytes(num: int) -> bytes:
|
||||||
|
"""itsdangerous 时间戳编码:8 字节大端并去前导零(struct.pack('>Q') + lstrip)。"""
|
||||||
|
return num.to_bytes(8, 'big').lstrip(b'\x00') or b'\x00'
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(secret: str, session: dict[str, Any]) -> str:
|
||||||
|
"""把会话字典序列化为 Flask/itsdangerous 兼容的签名 Cookie 值。"""
|
||||||
|
payload = base64.b64encode(
|
||||||
|
json.dumps(session, separators=(',', ':')).encode('utf-8')).decode('ascii')
|
||||||
|
ts = base64.b64encode(_int_to_bytes(int(time.time()))).decode('ascii').rstrip('=')
|
||||||
|
value = f'{payload}.{ts}'
|
||||||
|
sig = hmac.new(_derive_key(secret), value.encode('utf-8'), _DIGEST).digest()
|
||||||
|
return f'{value}.{base64.b64encode(sig).decode("ascii").rstrip("=")}'
|
||||||
|
|
||||||
|
|
||||||
|
def loads(secret: str, value: str) -> dict[str, Any] | None:
|
||||||
|
"""校验签名并解出会话字典;无效/被篡改返回 None。
|
||||||
|
|
||||||
|
注:Flask 的会话 JSON 采用「带空格」的 compact 编码(`{"a": 1}`),
|
||||||
|
解码时直接 json.loads 即可,两种空白形式都能解析。
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload, ts, sig = value.rsplit('.', 2)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
value_no_sig = f'{payload}.{ts}'
|
||||||
|
expected = hmac.new(_derive_key(secret), value_no_sig.encode('utf-8'), _DIGEST).digest()
|
||||||
|
pad = '=' * (-len(sig) % 4)
|
||||||
|
try:
|
||||||
|
actual = base64.urlsafe_b64decode(sig + pad)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not hmac.compare_digest(expected, actual):
|
||||||
|
return None
|
||||||
|
pad_p = '=' * (-len(payload) % 4)
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(payload + pad_p)
|
||||||
|
data = json.loads(raw.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_uid(session: dict[str, Any] | None) -> int | None:
|
||||||
|
"""取会话中的站点用户 id(Flask 存的是 int,直接可用)。"""
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
val = session.get(SESSION_KEY)
|
||||||
|
return int(val) if val else None
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Flask 兼容会话编解码的单元测试(不依赖网络)。"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from flask.sessions import SecureCookieSessionInterface
|
||||||
|
|
||||||
|
from core import session as cs
|
||||||
|
|
||||||
|
SECRET = 'unit-test-secret-key'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def flask_serializer():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = SECRET
|
||||||
|
return SecureCookieSessionInterface().get_signing_serializer(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _flask_cookie(ser, data):
|
||||||
|
return ser.dumps(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flask_to_core(flask_serializer):
|
||||||
|
"""Flask 签发的会话 Cookie 能被 core.session 解出。"""
|
||||||
|
cookie = _flask_cookie(flask_serializer, {'site_uid': 26, 'site_uid_seq': 5})
|
||||||
|
data = cs.loads(SECRET, cookie)
|
||||||
|
assert data == {'site_uid': 26, 'site_uid_seq': 5}
|
||||||
|
assert cs.get_uid(data) == 26
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_to_flask(flask_serializer):
|
||||||
|
"""core.session 签发(含时间戳)的 Cookie 能被 Flask 解出。"""
|
||||||
|
cookie = cs.dumps(SECRET, {'site_uid': 74, 'site_uid_seq': 8})
|
||||||
|
assert flask_serializer.loads(cookie) == {'site_uid': 74, 'site_uid_seq': 8}
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_to_flask_expiry(flask_serializer):
|
||||||
|
"""时间戳格式正确:Flask 能按 max_age 校验(未过期可解)。"""
|
||||||
|
cookie = cs.dumps(SECRET, {'site_uid': 1})
|
||||||
|
assert flask_serializer.loads(cookie, max_age=3600) == {'site_uid': 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tampered_returns_none(flask_serializer):
|
||||||
|
cookie = _flask_cookie(flask_serializer, {'site_uid': 26})
|
||||||
|
assert cs.loads(SECRET, cookie[:-3] + 'xyz') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_key_returns_none(flask_serializer):
|
||||||
|
cookie = _flask_cookie(flask_serializer, {'site_uid': 26})
|
||||||
|
assert cs.loads('wrong-secret', cookie) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_returns_none():
|
||||||
|
assert cs.loads(SECRET, 'not-a-cookie') is None
|
||||||
|
assert cs.loads(SECRET, '') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_selfconsistency():
|
||||||
|
payload = {'site_uid': 9, 'site_uid_seq': 3, 'site_uid_ts': int(time.time())}
|
||||||
|
assert cs.loads(SECRET, cs.dumps(SECRET, payload)) == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_uid_none():
|
||||||
|
assert cs.get_uid(None) is None
|
||||||
|
assert cs.get_uid({}) is None
|
||||||
@@ -18,6 +18,8 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from core.config import APP_VERSION
|
from core.config import APP_VERSION
|
||||||
|
from web.errors import install_error_handlers
|
||||||
|
from web.routers import music as music_router
|
||||||
from web.routers import site as site_router
|
from web.routers import site as site_router
|
||||||
from web.routers import system as system_router
|
from web.routers import system as system_router
|
||||||
|
|
||||||
@@ -48,6 +50,9 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI:
|
|||||||
# 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比)
|
# 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比)
|
||||||
app.include_router(system_router.router, prefix='/api/v1', tags=['system'])
|
app.include_router(system_router.router, prefix='/api/v1', tags=['system'])
|
||||||
app.include_router(site_router.router, prefix='/api/v1', tags=['site'])
|
app.include_router(site_router.router, prefix='/api/v1', tags=['site'])
|
||||||
|
app.include_router(music_router.router, prefix='/api/v1', tags=['music'])
|
||||||
|
|
||||||
|
install_error_handlers(app)
|
||||||
|
|
||||||
if serve_flask_fallback:
|
if serve_flask_fallback:
|
||||||
_mount_flask_fallback(app)
|
_mount_flask_fallback(app)
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""FastAPI 依赖:会话解析、当前用户、登录校验。
|
||||||
|
|
||||||
|
与 Flask 侧 auth_system 的语义对齐(迁移期两栈共用同一 Cookie、同一站点库):
|
||||||
|
- 会话 Cookie 经 core.session 解码(Flask 兼容签名),密钥同源
|
||||||
|
- 封禁账号(status 0/2):按未登录处理
|
||||||
|
- 顶号校验:会话内的 `site_uid_seq` 必须与库中 `login_seq` 一致,否则视为失效
|
||||||
|
- 剩余次数/账本预扣属付费流程,待付费路由迁移时再补(此处先覆盖免费端点)
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import Cookie, Depends
|
||||||
|
|
||||||
|
from core import repo
|
||||||
|
from core import session as sess
|
||||||
|
from web.errors import AppError
|
||||||
|
|
||||||
|
_SESSION_COOKIE = 'session'
|
||||||
|
|
||||||
|
|
||||||
|
def secret_key() -> str:
|
||||||
|
"""会话签名密钥(与 Flask 同源:data/secret.key)。"""
|
||||||
|
import auth_system
|
||||||
|
return auth_system.get_secret_key()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SiteUser:
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
credits: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self.role == 'admin'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unlimited(self) -> bool:
|
||||||
|
return self.is_admin or self.credits == -1
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cookie(session_cookie: str | None) -> dict[str, Any] | None:
|
||||||
|
return sess.loads(secret_key(), session_cookie or '')
|
||||||
|
|
||||||
|
|
||||||
|
async def current_user(
|
||||||
|
session_cookie: Annotated[str | None, Cookie(alias=_SESSION_COOKIE)] = None,
|
||||||
|
) -> SiteUser | None:
|
||||||
|
"""当前登录用户(未登录/失效返回 None)。"""
|
||||||
|
data = _parse_cookie(session_cookie)
|
||||||
|
uid = sess.get_uid(data)
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
row = await repo.get_user_by_id(uid)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
status = row.get('status', 1)
|
||||||
|
if status in (0, 2): # 禁用/封禁:按未登录处理
|
||||||
|
return None
|
||||||
|
# 顶号校验:库有 login_seq 时会话版本必须严格一致
|
||||||
|
db_seq = int(row.get('login_seq') or 0)
|
||||||
|
sess_seq = int((data or {}).get(sess.SESSION_KEY + '_seq') or 0)
|
||||||
|
if db_seq and sess_seq != db_seq:
|
||||||
|
return None
|
||||||
|
return SiteUser(
|
||||||
|
id=int(row['id']),
|
||||||
|
username=row['username'],
|
||||||
|
role=row['role'],
|
||||||
|
credits=int(row.get('duration_seconds') or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_login(
|
||||||
|
user: Annotated[SiteUser | None, Depends(current_user)],
|
||||||
|
) -> SiteUser:
|
||||||
|
"""登录闸:未登录抛 401(与 Flask login_required 同形包体),已登录返回用户。"""
|
||||||
|
if user is None:
|
||||||
|
raise AppError('请先登录后再使用解析功能', 401, error_code='unauthorized')
|
||||||
|
return user
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""统一应用异常与错误响应。
|
||||||
|
|
||||||
|
FastAPI 依赖里无法直接「返回响应短路」,因此用抛出 AppError + 全局异常处理器
|
||||||
|
的方式,产出与 Flask 侧同形的 JSON 包体({status, success, message, error_code})。
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
from web.response import error
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""业务异常:携带 HTTP 状态码与可选 error_code。"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 400, error_code: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error_code = error_code
|
||||||
|
|
||||||
|
|
||||||
|
def install_error_handlers(app) -> None:
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def _app_error(_request: Request, exc: AppError):
|
||||||
|
return error(exc.message, exc.status_code, exc.error_code)
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def _http_error(_request: Request, exc: StarletteHTTPException) -> Any:
|
||||||
|
# 404/405 等也统一为本站包体(Flask 侧 404 返回 {'success': False, ...})
|
||||||
|
msg = {404: '请求的资源不存在', 405: '请求方法不允许'}.get(exc.status_code,
|
||||||
|
str(exc.detail or '请求错误'))
|
||||||
|
return error(msg, exc.status_code)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def _validation_error(_request: Request, exc: RequestValidationError):
|
||||||
|
return error('请求参数错误', 400)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""公网限流(FastAPI 栈)——按 IP 滑动窗口,与 Flask 侧 _rate_ok 同一实现口径。
|
||||||
|
|
||||||
|
复用 core.state.RATE_BUCKETS/_RATE_LOCK,使两栈在迁移期共享同一批限流桶
|
||||||
|
(同一 IP 无论打到哪个栈都计入同一窗口),避免出现「绕到另一个框架即绕过限流」。
|
||||||
|
"""
|
||||||
|
import collections
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 保持与 Flask 侧常量同源
|
||||||
|
from core.state import RATE_BUCKETS, RATE_LOCK
|
||||||
|
from web.errors import AppError
|
||||||
|
|
||||||
|
# 免于限流的端点(试听流/进度轮询,与 Flask _QUIET/_rate_limit_guard 同口径)
|
||||||
|
_EXEMPT_PREFIXES = ('/audio_stream',)
|
||||||
|
_EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP,与 Flask 同策略。"""
|
||||||
|
import auth_system
|
||||||
|
remote = request.client.host if request.client else '-'
|
||||||
|
if remote and remote != '-' and auth_system._is_loopback_ip(remote):
|
||||||
|
xff = request.headers.get('x-forwarded-for')
|
||||||
|
if xff:
|
||||||
|
ip = auth_system._trusted_proxy_ip(xff)
|
||||||
|
if ip and not auth_system._is_loopback_ip(ip):
|
||||||
|
return ip
|
||||||
|
xreal = request.headers.get('x-real-ip')
|
||||||
|
if xreal:
|
||||||
|
ip = auth_system._trusted_proxy_ip(xreal)
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
return remote or '-'
|
||||||
|
|
||||||
|
|
||||||
|
def rate_ok(key: str, limit: int, window: int) -> bool:
|
||||||
|
"""滑动窗口限流:True=放行。与 Flask main._rate_ok 同算法、同桶容器。"""
|
||||||
|
now = time.time()
|
||||||
|
with RATE_LOCK:
|
||||||
|
dq = RATE_BUCKETS.get(key)
|
||||||
|
if dq is None:
|
||||||
|
if len(RATE_BUCKETS) > 8000:
|
||||||
|
for k in [k for k, v in RATE_BUCKETS.items() if not v or now - v[-1] > window]:
|
||||||
|
RATE_BUCKETS.pop(k, None)
|
||||||
|
dq = collections.deque()
|
||||||
|
RATE_BUCKETS[key] = dq
|
||||||
|
while dq and now - dq[0] > window:
|
||||||
|
dq.popleft()
|
||||||
|
if len(dq) >= limit:
|
||||||
|
return False
|
||||||
|
dq.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def guard(request: Request, *, bucket: str, limit: int, window: int) -> None:
|
||||||
|
"""限流闸:超限抛 429(与 Flask 包体同形)。豁免端点直接放行。"""
|
||||||
|
path = request.url.path
|
||||||
|
if path in _EXEMPT_PATHS or path.startswith(_EXEMPT_PREFIXES):
|
||||||
|
return
|
||||||
|
if not rate_ok(f'{bucket}:' + client_ip(request), limit, window):
|
||||||
|
raise AppError('请求过于频繁,请稍后再试', 429)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""音乐查询端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||||
|
|
||||||
|
已迁移(免费解析类,需登录,不扣次数):
|
||||||
|
- POST/GET /api/v1/toplist ↔ Flask /toplist
|
||||||
|
- POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
|
||||||
|
- POST/GET /api/v1/comments ↔ Flask /comments
|
||||||
|
- POST/GET /api/v1/search ↔ Flask /search
|
||||||
|
|
||||||
|
参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。
|
||||||
|
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
|
import music_api
|
||||||
|
from core import repo
|
||||||
|
from core.state import SERVER_NICK_CACHE
|
||||||
|
from music_api import APIException
|
||||||
|
from web import ratelimit
|
||||||
|
from web.deps import SiteUser, require_login
|
||||||
|
from web.response import error, success
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
LoginUser = Annotated[SiteUser, Depends(require_login)]
|
||||||
|
|
||||||
|
_SERVER_NICK_LOCK: asyncio.Lock | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _nick_lock() -> asyncio.Lock:
|
||||||
|
global _SERVER_NICK_LOCK
|
||||||
|
if _SERVER_NICK_LOCK is None:
|
||||||
|
_SERVER_NICK_LOCK = asyncio.Lock()
|
||||||
|
return _SERVER_NICK_LOCK
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_data(request: Request) -> dict[str, Any]:
|
||||||
|
"""三路兼容取参:GET query 优先,其次 POST JSON,最后表单。"""
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
if request.method == 'GET':
|
||||||
|
data.update(dict(request.query_params))
|
||||||
|
return data
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
if isinstance(body, dict):
|
||||||
|
data.update(body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not data:
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
data.update(dict(form.items()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_id(raw: Any) -> str:
|
||||||
|
"""URL/纯数字 → ID(与 Flask _extract_music_id 的查询参数口径一致)。"""
|
||||||
|
s = str(raw or '').strip()
|
||||||
|
if 'music.163.com' in s:
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
try:
|
||||||
|
q = parse_qs(urlsplit(s).query)
|
||||||
|
for key in ('id', 'songid'):
|
||||||
|
if q.get(key):
|
||||||
|
return str(q[key][0]).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
m = re.search(r'[?&#]id=(\d+)', s)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _cookies() -> dict[str, str]:
|
||||||
|
"""全局账号 Cookie(管理员 cookie.txt)。"""
|
||||||
|
return music_api.load_cookies()
|
||||||
|
|
||||||
|
|
||||||
|
async def mask_server_nickname(nick: str, user: SiteUser) -> str:
|
||||||
|
"""非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。
|
||||||
|
|
||||||
|
- 管理员本人可见真实昵称;
|
||||||
|
- 缓存 10 分钟内复用;上游拿不到时回落站点库里的最后已知昵称继续遮蔽。
|
||||||
|
"""
|
||||||
|
if not nick or user.is_admin:
|
||||||
|
return nick
|
||||||
|
import time
|
||||||
|
|
||||||
|
def _replace(n):
|
||||||
|
return nick.replace(n, '云村小助手') if (n and n in nick) else nick
|
||||||
|
|
||||||
|
if SERVER_NICK_CACHE['nick'] is None or time.time() - SERVER_NICK_CACHE['ts'] > 600:
|
||||||
|
async with _nick_lock():
|
||||||
|
if SERVER_NICK_CACHE['nick'] is None or time.time() - SERVER_NICK_CACHE['ts'] > 600:
|
||||||
|
fetched = ''
|
||||||
|
try:
|
||||||
|
fetched = (await music_api.NeteaseAPI().get_account_info(_cookies()) or {}).get('nickname', '')
|
||||||
|
except Exception:
|
||||||
|
fetched = ''
|
||||||
|
if fetched:
|
||||||
|
SERVER_NICK_CACHE['nick'] = fetched
|
||||||
|
SERVER_NICK_CACHE['ts'] = time.time()
|
||||||
|
try:
|
||||||
|
await repo.set_setting('server_nickname', fetched)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
persisted = await repo.get_setting('server_nickname', '')
|
||||||
|
except Exception:
|
||||||
|
persisted = ''
|
||||||
|
if persisted:
|
||||||
|
SERVER_NICK_CACHE['nick'] = persisted
|
||||||
|
SERVER_NICK_CACHE['ts'] = time.time()
|
||||||
|
else:
|
||||||
|
# 库里也从未有过:缩短重试间隔,本次无遮蔽依据只能放行
|
||||||
|
SERVER_NICK_CACHE['ts'] = time.time() - 600 + 30
|
||||||
|
return _replace(SERVER_NICK_CACHE['nick'])
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route('/toplist', methods=['GET', 'POST'])
|
||||||
|
async def toplist(_user: LoginUser, request: Request):
|
||||||
|
"""官方榜单列表。"""
|
||||||
|
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
||||||
|
try:
|
||||||
|
result = await music_api.NeteaseAPI().get_toplist()
|
||||||
|
return success(result, '获取榜单列表成功')
|
||||||
|
except APIException:
|
||||||
|
return error('服务暂时不可用,请稍后再试', 500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route('/artist_songs', methods=['GET', 'POST'])
|
||||||
|
async def artist_songs(_user: LoginUser, request: Request):
|
||||||
|
"""歌手信息与热门歌曲。"""
|
||||||
|
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
||||||
|
data = await _request_data(request)
|
||||||
|
artist_id = data.get('id') or data.get('artist_id')
|
||||||
|
if artist_id in (None, ''):
|
||||||
|
return error('缺少 artist_id 参数')
|
||||||
|
artist_id = _extract_id(artist_id)
|
||||||
|
if not str(artist_id).strip().isdigit():
|
||||||
|
return error('无效的歌手ID,请输入数字ID或分享链接')
|
||||||
|
try:
|
||||||
|
result = await music_api.NeteaseAPI().get_artist_hot_songs(int(artist_id))
|
||||||
|
return success(result, '获取歌手歌曲成功')
|
||||||
|
except APIException:
|
||||||
|
return error('服务暂时不可用,请稍后再试', 500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route('/comments', methods=['GET', 'POST'])
|
||||||
|
async def comments(_user: LoginUser, request: Request):
|
||||||
|
"""歌曲评论(分页)。"""
|
||||||
|
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
||||||
|
data = await _request_data(request)
|
||||||
|
song_id = data.get('id') or data.get('song_id')
|
||||||
|
limit = max(1, min(_safe_int(data.get('limit'), 20), 100))
|
||||||
|
offset = max(0, _safe_int(data.get('offset'), 0))
|
||||||
|
if song_id in (None, ''):
|
||||||
|
return error('缺少 song_id 参数')
|
||||||
|
song_id = _extract_id(song_id)
|
||||||
|
if not str(song_id).strip().isdigit():
|
||||||
|
return error('无效的歌曲ID,请输入数字ID或分享链接')
|
||||||
|
try:
|
||||||
|
result = await music_api.NeteaseAPI().get_song_comments(int(song_id), limit, offset)
|
||||||
|
return success(result, '获取评论成功')
|
||||||
|
except APIException:
|
||||||
|
return error('服务暂时不可用,请稍后再试', 500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route('/search', methods=['GET', 'POST'])
|
||||||
|
async def search(user: LoginUser, request: Request):
|
||||||
|
"""搜索(歌曲/专辑/歌手/歌单),offset 透传上游。"""
|
||||||
|
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
||||||
|
data = await _request_data(request)
|
||||||
|
keyword = data.get('keyword') or data.get('keywords') or data.get('q')
|
||||||
|
limit = max(1, _safe_int(data.get('limit'), 30))
|
||||||
|
offset = max(0, _safe_int(data.get('offset'), 0))
|
||||||
|
search_type = data.get('type', '1')
|
||||||
|
if not keyword:
|
||||||
|
return error('缺少 keyword 参数')
|
||||||
|
if limit > 100:
|
||||||
|
limit = 100
|
||||||
|
try:
|
||||||
|
stype = int(search_type)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return error('参数格式错误:type 必须为数字')
|
||||||
|
try:
|
||||||
|
result = await music_api.NeteaseAPI().search_music(
|
||||||
|
str(keyword), _cookies(), limit, stype, offset=offset)
|
||||||
|
except APIException:
|
||||||
|
return error('服务暂时不可用,请稍后再试', 500)
|
||||||
|
|
||||||
|
# 向后兼容字段 + 服务器昵称遮蔽(与 Flask 侧同口径)
|
||||||
|
if result:
|
||||||
|
for song in result:
|
||||||
|
if song.get('type', 'song') == 'song' and 'artists' in song:
|
||||||
|
song['artist_string'] = song['artists']
|
||||||
|
if song.get('type') == 'playlist':
|
||||||
|
if song.get('creator'):
|
||||||
|
song['creator'] = await mask_server_nickname(song['creator'], user)
|
||||||
|
if song.get('name'):
|
||||||
|
song['name'] = await mask_server_nickname(song['name'], user)
|
||||||
|
return success(result, '搜索完成')
|
||||||
Reference in new issue
Block a user