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。
This commit is contained in:
snowy-yang committed 2026-09-26 10:20:46 +08:00
1 parent 9df0028195
commit cd0f1f78a3
13 files changed
+274 -339

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""路由公共工具:请求取参、ID 归一化、Cookie、安全转换。
这些函数原先在 11 个 router 模块里各复制一份(约 200 行重复),
现集中于此,行为与各副本完全一致(原逻辑逐字保留)。
"""
import re
from typing import Any
from fastapi import Request
# music.163.com 链接 / 纯数字 → ID(与 Flask MusicAPIService._extract_music_id 的
# 查询参数口径一致;短链展开由调用方按需先做)
_ID_QUERY_RE = re.compile(r'[?&#]id=(\d+)')
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:
"""用户输入的安全整数解析:垃圾值回退默认值(而非 500)。"""
try:
return int(value)
except (TypeError, ValueError):
return default
def extract_id(raw: Any) -> str:
"""URL / 分享链接 / 纯数字 → 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 = _ID_QUERY_RE.search(s)
if m:
return m.group(1)
return s
def cookies() -> dict[str, str]:
"""全局账号 Cookie(管理员 cookie.txt)。"""
import music_api
return music_api.load_cookies()
def dedup_ids(raw_ids: Any, transform=extract_id) -> list[str]:
"""ID 列表去重(保持首次出现顺序):与 Flask 侧「提取→去重」计费口径一致。"""
if isinstance(raw_ids, str):
raw_list = raw_ids.split(',')
else:
raw_list = [str(x) for x in (raw_ids or [])]
out: list[str] = []
seen: set[str] = set()
for x in raw_list:
s = str(transform(x) or '').strip()
if s and s not in seen:
seen.add(s)
out.append(s)
return out
+3 -19
View File
@@ -17,6 +17,9 @@ from core.state import VIP_CACHE, VIP_PROBE_LOCK, VIP_USER_CACHE
from music_api import APIException from music_api import APIException
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user, require_admin 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 from web.response import error, success
router = APIRouter() router = APIRouter()
@@ -66,25 +69,6 @@ async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
_VIP_PROCESS_LOCK.release() _VIP_PROCESS_LOCK.release()
async def _request_data(request: Request) -> dict[str, Any]:
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
@router.api_route('/account/vip', methods=['GET', 'POST']) @router.api_route('/account/vip', methods=['GET', 'POST'])
async def account_vip( async def account_vip(
+3 -25
View File
@@ -16,7 +16,7 @@ _get_cookie_info 仍为同步文件操作,经 asyncio.to_thread 执行。
""" """
import asyncio import asyncio
import logging import logging
from typing import Annotated, Any from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
@@ -24,6 +24,8 @@ from core.state import GLOBAL_MUSIC_U_CACHE, REQ_LOG, VIP_CACHE
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user from web.deps import SiteUser, current_user
from web.errors import AppError from web.errors import AppError
from web.request_utils import request_data as _request_data
from web.request_utils import safe_int as _safe_int
from web.response import error, success from web.response import error, success
router = APIRouter() router = APIRouter()
@@ -41,32 +43,8 @@ async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]
AdminUser = Annotated[SiteUser, Depends(_require_admin)] AdminUser = Annotated[SiteUser, Depends(_require_admin)]
async def _request_data(request: Request) -> dict[str, Any]:
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 _admin_client(): def _admin_client():
from qr_login import QRLoginClient from qr_login import QRLoginClient
+111 -38
View File
@@ -17,7 +17,7 @@
import asyncio import asyncio
import os import os
import time import time
from typing import Annotated, Any from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -28,7 +28,12 @@ from core import db, repo
from core import session as cs from core import session as cs
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user from web.deps import SiteUser, current_user
from web.request_utils import (
request_data as _request_data,
)
from web.response import error, success from web.response import error, success
from web.serializers import format_credits as _format_credits
from web.serializers import user_public as _user_public
router = APIRouter() router = APIRouter()
@@ -36,45 +41,8 @@ SESSION_COOKIE = 'session'
_SESSION_MAX_AGE = 7 * 86400 _SESSION_MAX_AGE = 7 * 86400
def _format_credits(count: int) -> str:
if count < 0:
return '不限次数'
if count <= 0:
return '已用完'
return f'{count} 次'
def _user_public(row: dict) -> dict:
return {
'id': row['id'],
'username': row['username'],
'email': row.get('email'),
'role': row['role'],
'duration_seconds': row['duration_seconds'],
'created_at': row.get('created_at'),
'last_login': row.get('last_login'),
'ann_seen': row.get('ann_seen'),
}
async def _request_data(request: Request) -> dict[str, Any]:
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 _cookie_kwargs() -> dict: def _cookie_kwargs() -> dict:
@@ -224,3 +192,108 @@ async def login(request: Request):
}, '登录成功', uid, seq) }, '登录成功', uid, seq)
except Exception: except Exception:
return error('登录失败,请稍后重试', 500) return error('登录失败,请稍后重试', 500)
# ============ 管理员两步验证 Step2 / Step3 ============
@router.post('/auth/admin_login/email')
async def admin_login_email(request: Request):
"""管理员 2FA Step2:比对绑定邮箱 → 发送 admin2fa 验证码。"""
import hmac as _hmac
from auth_routes import (
_ADMIN2FA_LAST_SEND,
_ADMIN2FA_SEND_GAP,
_ADMIN_2FA,
_ADMIN_2FA_MAX_ATTEMPTS,
_admin2fa_mask,
_admin2fa_take,
)
data = await _request_data(request)
ip = ratelimit.client_ip(request)
ticket, v = _admin2fa_take(str(data.get('ticket') or ''))
if not v:
return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired')
email_input = auth._normalize_email(str(data.get('email') or ''))
# 第二因子比对走常时比较
if (not email_input or not v.get('email')
or not _hmac.compare_digest(email_input, v['email'])):
v['attempts'] += 1
await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '绑定邮箱不匹配')
if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS:
_ADMIN_2FA.pop(ticket, None)
return error('错误次数过多,请重新开始登录')
return error(f'绑定邮箱不正确,还可尝试 {_ADMIN_2FA_MAX_ATTEMPTS - v["attempts"]} 次')
last_send = _ADMIN2FA_LAST_SEND.get(v['username'], 0)
if time.time() - last_send < _ADMIN2FA_SEND_GAP:
return error('验证码发送过于频繁,请 1 分钟后再试')
try:
await asyncio.to_thread(auth.send_verify_code, v['email'], 'admin2fa')
_ADMIN2FA_LAST_SEND[v['username']] = time.time()
await asyncio.to_thread(auth.add_log, 'success', v['username'], ip, '管理员登录',
f'绑定邮箱验证通过,验证码已发送({_admin2fa_mask(v["email"])})')
return success(message='验证码已发送至管理员邮箱,请查收')
except (ValueError, RuntimeError) as e:
return error(str(e))
except Exception:
return error('验证码发送失败,请稍后重试', 500)
@router.post('/auth/admin_login/verify')
async def admin_login_verify(request: Request):
"""管理员 2FA Step3:验证码原子消费 → 建立管理员会话。"""
from auth_routes import (
_ADMIN_2FA,
_ADMIN_2FA_MAX_ATTEMPTS,
_admin2fa_take,
)
data = await _request_data(request)
ip = ratelimit.client_ip(request)
ticket, v = _admin2fa_take(str(data.get('ticket') or ''))
if not v:
return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired')
code = str(data.get('code') or '').strip()
if not await asyncio.to_thread(auth.check_verify_code, v['email'], code, 'admin2fa'):
v['attempts'] += 1
if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS:
_ADMIN_2FA.pop(ticket, None)
return error('错误次数过多,请重新开始登录')
await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '验证码错误')
return error('验证码错误或已过期')
_ADMIN_2FA.pop(ticket, None) # 一次性
user = await asyncio.to_thread(auth.get_user_by_id, v['user_id'])
if not user or user['role'] != 'admin':
return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired')
keys = user.keys()
status = user['status'] if 'status' in keys else 1
if status == 2:
await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '封禁账号尝试登录')
return error('账号已被封禁,请联系管理员')
if status == 0:
return error('账号已被禁用,请联系管理员')
uid = int(user['id'])
now = int(time.time())
await db.execute('UPDATE users SET last_active=?, last_login=?, '
'login_seq=COALESCE(login_seq,0)+1 WHERE id=?', (now, now, uid))
seq_row = await repo.get_user_by_id(uid)
seq = int((seq_row or {}).get('login_seq') or 1)
banned, _info = await asyncio.to_thread(auth.record_login_location, user, ip)
if banned:
await db.execute('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (uid,))
return error('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
fresh = await repo.get_user_by_id(uid) or {}
await asyncio.to_thread(auth.add_log, 'success', v['username'], ip, '管理员登录', '两步验证通过')
return _login_response({
'user': _user_public(fresh),
'duration_text': _format_credits(int(fresh.get('duration_seconds') or 0)),
'redirect': 'admin',
}, '登录成功', uid, seq)
+2 -18
View File
@@ -14,30 +14,14 @@ from core import repo
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user from web.deps import SiteUser, current_user
from web.response import error, success from web.response import error, success
from web.serializers import format_credits as _format_credits
from web.serializers import user_public as _user_public
router = APIRouter() router = APIRouter()
def _format_credits(count: int) -> str:
if count < 0:
return '不限次数'
if count <= 0:
return '已用完'
return f'{count} 次'
def _user_public(row: dict) -> dict:
return {
'id': row['id'],
'username': row['username'],
'email': row.get('email'),
'role': row['role'],
'duration_seconds': row['duration_seconds'],
'created_at': row.get('created_at'),
'last_login': row.get('last_login'),
'ann_seen': row.get('ann_seen'),
}
@router.api_route('/auth/me', methods=['GET', 'POST']) @router.api_route('/auth/me', methods=['GET', 'POST'])
async def auth_me( async def auth_me(
+3 -19
View File
@@ -23,6 +23,9 @@ from fastapi.responses import FileResponse
from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK
from web.deps import SiteUser, require_login from web.deps import SiteUser, require_login
from web.request_utils import (
request_data as _request_data,
)
from web.response import error from web.response import error
router = APIRouter() router = APIRouter()
@@ -30,25 +33,6 @@ router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)] LoginUser = Annotated[SiteUser, Depends(require_login)]
async def _request_data(request: Request) -> dict[str, Any]:
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 _owned_by(task: dict[str, Any], user: SiteUser) -> bool: def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
return int(task.get('user_id') or 0) == int(user.id) return int(task.get('user_id') or 0) == int(user.id)
+9 -44
View File
@@ -21,12 +21,16 @@ from fastapi import APIRouter, Depends, Request
from core import media from core import media
from core.config import VALID_QUALITY_LEVELS from core.config import VALID_QUALITY_LEVELS
from core.state import REQ_LOG
from music_api import APIException from music_api import APIException
from web import ratelimit from web import ratelimit
from web.billing import require_billing from web.billing import require_billing
from web.deps import SiteUser, current_user, require_login from web.deps import SiteUser, current_user, require_login
from web.request_utils import cookies as _cookies
from web.request_utils import extract_id as _extract_id
from web.request_utils import request_data as _request_data
from web.request_utils import safe_int as _safe_int
from web.response import error, success from web.response import error, success
from web.serializers import record_req_log as _record_log
from web.tasks_registry import register_download_task, start_task from web.tasks_registry import register_download_task, start_task
router = APIRouter() router = APIRouter()
@@ -36,41 +40,12 @@ LoginUser = Annotated[SiteUser, Depends(require_login)]
logger = logging.getLogger('web.download') logger = logging.getLogger('web.download')
async def _request_data(request: Request) -> dict[str, Any]:
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 _extract_id(raw: Any) -> str:
from web.routers.music import _extract_id as _ex
return _ex(raw)
def _parse_meta_fields(raw: Any): def _parse_meta_fields(raw: Any):
from core.utils import parse_meta_fields from core.utils import parse_meta_fields
return parse_meta_fields(raw) return parse_meta_fields(raw)
def _record_log(action: str, ip: str, song: str) -> None:
REQ_LOG.append({'action': action, 'ip': ip, 'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S')})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]
def _dedup_ids(raw_ids: Any) -> list[str]: def _dedup_ids(raw_ids: Any) -> list[str]:
@@ -88,11 +63,6 @@ def _dedup_ids(raw_ids: Any) -> list[str]:
return out return out
def _cookies(data: dict[str, Any]) -> dict[str, str]:
import music_api
return music_api.load_cookies()
async def _resolve_personal(data: dict[str, Any], user: SiteUser | None, request: Request): async def _resolve_personal(data: dict[str, Any], user: SiteUser | None, request: Request):
from web.routers.music import _resolve_user_cookies from web.routers.music import _resolve_user_cookies
return await _resolve_user_cookies(data, user, request) return await _resolve_user_cookies(data, user, request)
@@ -135,7 +105,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ
if not cookies: if not cookies:
return await _fail('请先扫码登录个人账号后再使用个人解析', 401) return await _fail('请先扫码登录个人账号后再使用个人解析', 401)
else: else:
cookies = _cookies(data) cookies = _cookies()
filename_format = data.get('filename_format', 'artist_song') filename_format = data.get('filename_format', 'artist_song')
write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes')
@@ -276,7 +246,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
concurrency = 8 concurrency = 8
concurrency = max(1, min(10, concurrency)) concurrency = max(1, min(10, concurrency))
cookies = _cookies(data) cookies = _cookies()
device_id = str(data.get('device_id') or '').strip()[:64] device_id = str(data.get('device_id') or '').strip()[:64]
import main as legacy import main as legacy
await asyncio.to_thread(legacy._touch_device, device_id) await asyncio.to_thread(legacy._touch_device, device_id)
@@ -322,11 +292,6 @@ def _podcast_billing_count(data: dict[str, Any]) -> int:
return max(1, min(len(ids), limit)) return max(1, min(len(ids), limit))
def _safe_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _podcast_quality(data: dict[str, Any]) -> str: def _podcast_quality(data: dict[str, Any]) -> str:
@@ -362,7 +327,7 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user
await billing.refund_open('rejected') await billing.refund_open('rejected')
return error('请先扫码登录个人账号后再使用个人解析', 401) return error('请先扫码登录个人账号后再使用个人解析', 401)
else: else:
cookies = _cookies(data) cookies = _cookies()
device_id = str(data.get('device_id') or '').strip()[:64] device_id = str(data.get('device_id') or '').strip()[:64]
import main as legacy import main as legacy
@@ -413,7 +378,7 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
return error('缺少 radio_id 或 ids 参数') return error('缺少 radio_id 或 ids 参数')
limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500)) limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500))
quality = _podcast_quality(data) quality = _podcast_quality(data)
cookies = _cookies(data) cookies = _cookies()
import podcast_api import podcast_api
if raw_radio: if raw_radio:
+6 -56
View File
@@ -17,7 +17,6 @@
import asyncio import asyncio
import hmac import hmac
import logging import logging
import re
import time import time
from typing import Annotated, Any from typing import Annotated, Any
@@ -26,11 +25,16 @@ from fastapi import APIRouter, Depends, Request
import music_api import music_api
from core import media, repo from core import media, repo
from core.config import VALID_QUALITY_LEVELS from core.config import VALID_QUALITY_LEVELS
from core.state import GLOBAL_MUSIC_U_CACHE, REQ_LOG, SERVER_NICK_CACHE from core.state import GLOBAL_MUSIC_U_CACHE, SERVER_NICK_CACHE
from music_api import APIException from music_api import APIException
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, require_login from web.deps import SiteUser, require_login
from web.request_utils import cookies as _cookies
from web.request_utils import extract_id as _extract_id
from web.request_utils import request_data as _request_data
from web.request_utils import safe_int as _safe_int
from web.response import error, success from web.response import error, success
from web.serializers import record_req_log as _record_log
router = APIRouter() router = APIRouter()
@@ -50,56 +54,12 @@ def _nick_lock() -> asyncio.Lock:
return _SERVER_NICK_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: async def mask_server_nickname(nick: str, user: SiteUser) -> str:
"""非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。 """非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。
@@ -269,16 +229,6 @@ async def _resolve_user_cookies(
return ck return ck
def _record_log(action: str, ip: str, song: str) -> None:
"""用户解析/下载操作记录(IP)——写入共享 REQ_LOG(与 Flask _record_log 同口径)。"""
REQ_LOG.append({
'action': action,
'ip': ip,
'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]
# ============ 迁移端:专辑 / 歌单 / 歌曲 ============ # ============ 迁移端:专辑 / 歌单 / 歌曲 ============
+6 -36
View File
@@ -11,7 +11,7 @@
让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。 让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。
""" """
import asyncio import asyncio
from typing import Annotated, Any from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
@@ -20,54 +20,24 @@ from core import media
from music_api import APIException from music_api import APIException
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, require_login from web.deps import SiteUser, require_login
from web.request_utils import cookies as _cookies
from web.request_utils import extract_id as _extract_id
from web.request_utils import request_data as _request_data
from web.request_utils import safe_int as _safe_int
from web.response import error, success from web.response import error, success
from web.serializers import record_req_log as _record_log
router = APIRouter() router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)] LoginUser = Annotated[SiteUser, Depends(require_login)]
async def _request_data(request: Request) -> dict[str, Any]:
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 _cookies() -> dict[str, str]:
import music_api
return music_api.load_cookies()
def _extract_id(raw: Any) -> str:
from web.routers.music import _extract_id as _ex
return _ex(raw)
def _record_log(action: str, ip: str, song: str) -> None:
from web.routers.music import _record_log as _rl
_rl(action, ip, song)
@router.api_route('/podcast/parse', methods=['GET', 'POST']) @router.api_route('/podcast/parse', methods=['GET', 'POST'])
async def podcast_parse(_user: LoginUser, request: Request): async def podcast_parse(_user: LoginUser, request: Request):
+5 -32
View File
@@ -18,7 +18,7 @@ import asyncio
import os import os
import sqlite3 import sqlite3
import time import time
from typing import Annotated, Any from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -29,7 +29,11 @@ from core import db, repo
from core import session as cs from core import session as cs
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user from web.deps import SiteUser, current_user
from web.request_utils import (
request_data as _request_data,
)
from web.response import error, success from web.response import error, success
from web.serializers import user_public as _user_public
router = APIRouter() router = APIRouter()
@@ -37,37 +41,6 @@ SESSION_COOKIE = 'session'
_SESSION_MAX_AGE = 7 * 86400 _SESSION_MAX_AGE = 7 * 86400
async def _request_data(request: Request) -> dict[str, Any]:
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 _user_public(row: dict) -> dict:
return {
'id': row['id'],
'username': row['username'],
'email': row.get('email'),
'role': row['role'],
'duration_seconds': row['duration_seconds'],
'created_at': row.get('created_at'),
'last_login': row.get('last_login'),
'ann_seen': row.get('ann_seen'),
}
def _login_cookie_response(payload: dict, message: str, uid: int, seq: int, def _login_cookie_response(payload: dict, message: str, uid: int, seq: int,
+3 -19
View File
@@ -29,6 +29,9 @@ from core.state import (
) )
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, require_login from web.deps import SiteUser, require_login
from web.request_utils import (
request_data as _request_data,
)
from web.response import error, success from web.response import error, success
router = APIRouter() router = APIRouter()
@@ -36,25 +39,6 @@ router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)] LoginUser = Annotated[SiteUser, Depends(require_login)]
async def _request_data(request: Request) -> dict[str, Any]:
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 _touch_device(device_id: str) -> None: def _touch_device(device_id: str) -> None:
"""记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。""" """记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。"""
+4 -33
View File
@@ -30,6 +30,10 @@ from core.config import VALID_QUALITY_LEVELS
from music_api import APIException from music_api import APIException
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user, require_login from web.deps import SiteUser, current_user, require_login
from web.request_utils import cookies as _cookies
from web.request_utils import extract_id as _extract_id
from web.request_utils import request_data as _request_data
from web.request_utils import safe_int as _safe_int
from web.response import error, success from web.response import error, success
router = APIRouter() router = APIRouter()
@@ -42,41 +46,8 @@ _MAX_PLAY_TRACKS = 500
_PLAY_CONCURRENCY = 4 _PLAY_CONCURRENCY = 4
async def _request_data(request: Request) -> dict[str, Any]:
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 _cookies() -> dict[str, str]:
return music_api.load_cookies()
def _extract_id(raw: Any) -> str:
from web.routers.music import _extract_id as _ex
return _ex(raw)
async def _resolve_user_cookies(data: dict[str, Any], user: SiteUser | None, request: Request): async def _resolve_user_cookies(data: dict[str, Any], user: SiteUser | None, request: Request):
from web.routers.music import _resolve_user_cookies as _r from web.routers.music import _resolve_user_cookies as _r
+36
View File
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""响应序列化与记录辅助(消除 router 间重复定义)。"""
import time
from typing import Any
from core.state import REQ_LOG
# 站点用户公开字段(与 Flask auth_system._user_public 的非 full 口径一致)
_PUBLIC_FIELDS = ('id', 'username', 'email', 'role', 'duration_seconds',
'created_at', 'last_login', 'ann_seen')
def format_credits(count: int) -> str:
"""次数文案(与 Flask auth_system.format_credits 逐字一致)。"""
if count < 0:
return '不限次数'
if count <= 0:
return '已用完'
return f'{count} 次'
def user_public(row: dict[str, Any]) -> dict[str, Any]:
"""用户公开字段(未登录/缺失字段安全)。"""
return {k: row.get(k) for k in _PUBLIC_FIELDS}
def record_req_log(action: str, ip: str, song: str) -> None:
"""用户解析/下载操作记录(写入共享 REQ_LOG,保留最近 2000 条)。"""
REQ_LOG.append({
'action': action,
'ip': ip,
'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]