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:
snowy-yang committed 2026-09-26 01:09:01 +08:00
1 parent c77ef40fd5
commit 2c55b55e03
9 files changed
+559 -6

No files matched your search

-6
View File
@@ -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
+5
View File
@@ -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:
+81
View File
@@ -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
+67
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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)
+64
View File
@@ -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)
+216
View File
@@ -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, '搜索完成')