diff --git a/web/request_utils.py b/web/request_utils.py new file mode 100644 index 0000000..519584f --- /dev/null +++ b/web/request_utils.py @@ -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 diff --git a/web/routers/account.py b/web/routers/account.py index 4ae097d..16f1bdf 100644 --- a/web/routers/account.py +++ b/web/routers/account.py @@ -17,6 +17,9 @@ 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() @@ -66,25 +69,6 @@ async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: _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']) async def account_vip( diff --git a/web/routers/admin.py b/web/routers/admin.py index e7251c9..9a9009f 100644 --- a/web/routers/admin.py +++ b/web/routers/admin.py @@ -16,7 +16,7 @@ _get_cookie_info 仍为同步文件操作,经 asyncio.to_thread 执行。 """ import asyncio import logging -from typing import Annotated, Any +from typing import Annotated 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.deps import SiteUser, current_user 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 router = APIRouter() @@ -41,32 +43,8 @@ async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)] 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(): from qr_login import QRLoginClient diff --git a/web/routers/auth.py b/web/routers/auth.py index 572c392..587e9ab 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -17,7 +17,7 @@ import asyncio import os import time -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse @@ -28,7 +28,12 @@ from core import db, repo from core import session as cs from web import ratelimit 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.serializers import format_credits as _format_credits +from web.serializers import user_public as _user_public router = APIRouter() @@ -36,45 +41,8 @@ SESSION_COOKIE = 'session' _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: @@ -224,3 +192,108 @@ async def login(request: Request): }, '登录成功', uid, seq) except Exception: 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) diff --git a/web/routers/auth_status.py b/web/routers/auth_status.py index 02cab50..e5e7852 100644 --- a/web/routers/auth_status.py +++ b/web/routers/auth_status.py @@ -14,30 +14,14 @@ from core import repo from web import ratelimit from web.deps import SiteUser, current_user 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() -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']) async def auth_me( diff --git a/web/routers/delivery.py b/web/routers/delivery.py index 7528dbf..b14a69a 100644 --- a/web/routers/delivery.py +++ b/web/routers/delivery.py @@ -23,6 +23,9 @@ from fastapi.responses import FileResponse from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK from web.deps import SiteUser, require_login +from web.request_utils import ( + request_data as _request_data, +) from web.response import error router = APIRouter() @@ -30,25 +33,6 @@ router = APIRouter() 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: return int(task.get('user_id') or 0) == int(user.id) diff --git a/web/routers/download.py b/web/routers/download.py index 0ca6e5a..05ce7b8 100644 --- a/web/routers/download.py +++ b/web/routers/download.py @@ -21,12 +21,16 @@ from fastapi import APIRouter, Depends, Request from core import media from core.config import VALID_QUALITY_LEVELS -from core.state import REQ_LOG from music_api import APIException from web import ratelimit from web.billing import require_billing 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.serializers import record_req_log as _record_log from web.tasks_registry import register_download_task, start_task router = APIRouter() @@ -36,41 +40,12 @@ LoginUser = Annotated[SiteUser, Depends(require_login)] 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): from core.utils import parse_meta_fields 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]: @@ -88,11 +63,6 @@ def _dedup_ids(raw_ids: Any) -> list[str]: 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): from web.routers.music import _resolve_user_cookies 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: return await _fail('请先扫码登录个人账号后再使用个人解析', 401) else: - cookies = _cookies(data) + cookies = _cookies() filename_format = data.get('filename_format', 'artist_song') 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 = max(1, min(10, concurrency)) - cookies = _cookies(data) + cookies = _cookies() device_id = str(data.get('device_id') or '').strip()[:64] import main as legacy 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)) -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: @@ -362,7 +327,7 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user await billing.refund_open('rejected') return error('请先扫码登录个人账号后再使用个人解析', 401) else: - cookies = _cookies(data) + cookies = _cookies() device_id = str(data.get('device_id') or '').strip()[:64] 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 参数') limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500)) quality = _podcast_quality(data) - cookies = _cookies(data) + cookies = _cookies() import podcast_api if raw_radio: diff --git a/web/routers/music.py b/web/routers/music.py index 16efe61..100264a 100644 --- a/web/routers/music.py +++ b/web/routers/music.py @@ -17,7 +17,6 @@ import asyncio import hmac import logging -import re import time from typing import Annotated, Any @@ -26,11 +25,16 @@ from fastapi import APIRouter, Depends, Request import music_api from core import media, repo 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 web import ratelimit 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.serializers import record_req_log as _record_log router = APIRouter() @@ -50,56 +54,12 @@ def _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 同口径)。 @@ -269,16 +229,6 @@ async def _resolve_user_cookies( 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] # ============ 迁移端:专辑 / 歌单 / 歌曲 ============ diff --git a/web/routers/podcast.py b/web/routers/podcast.py index 88e726a..9932f76 100644 --- a/web/routers/podcast.py +++ b/web/routers/podcast.py @@ -11,7 +11,7 @@ 让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。 """ import asyncio -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends, Request @@ -20,54 +20,24 @@ from core import media from music_api import APIException from web import ratelimit 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.serializers import record_req_log as _record_log router = APIRouter() 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']) async def podcast_parse(_user: LoginUser, request: Request): diff --git a/web/routers/register.py b/web/routers/register.py index b8a64cb..0df2741 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -18,7 +18,7 @@ import asyncio import os import sqlite3 import time -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse @@ -29,7 +29,11 @@ from core import db, repo from core import session as cs from web import ratelimit 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.serializers import user_public as _user_public router = APIRouter() @@ -37,37 +41,6 @@ SESSION_COOKIE = 'session' _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, diff --git a/web/routers/tasks.py b/web/routers/tasks.py index 1f20255..0d69474 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -29,6 +29,9 @@ from core.state import ( ) from web import ratelimit from web.deps import SiteUser, require_login +from web.request_utils import ( + request_data as _request_data, +) from web.response import error, success router = APIRouter() @@ -36,25 +39,6 @@ router = APIRouter() 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: """记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。""" diff --git a/web/routers/user.py b/web/routers/user.py index ab5b157..04203ed 100644 --- a/web/routers/user.py +++ b/web/routers/user.py @@ -30,6 +30,10 @@ from core.config import VALID_QUALITY_LEVELS from music_api import APIException from web import ratelimit 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 router = APIRouter() @@ -42,41 +46,8 @@ _MAX_PLAY_TRACKS = 500 _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): from web.routers.music import _resolve_user_cookies as _r diff --git a/web/serializers.py b/web/serializers.py new file mode 100644 index 0000000..0f323e2 --- /dev/null +++ b/web/serializers.py @@ -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]