From 2c55b55e0329e56a763d5906d28bee2fd8d35391 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:09:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(routes):=20=E8=BF=81=E7=A7=BB=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=B1=BB=E8=B7=AF=E7=94=B1=E5=88=B0=20FastAPI=20+=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=BA=92=E9=80=9A=20+=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF/=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话互通(迁移关键): - 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 一致。 --- .gitignore | 6 -- core/repo.py | 5 + core/session.py | 81 ++++++++++++++++ tests/test_session.py | 67 +++++++++++++ web/app.py | 5 + web/deps.py | 81 ++++++++++++++++ web/errors.py | 40 ++++++++ web/ratelimit.py | 64 +++++++++++++ web/routers/music.py | 216 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 core/session.py create mode 100644 tests/test_session.py create mode 100644 web/deps.py create mode 100644 web/errors.py create mode 100644 web/ratelimit.py create mode 100644 web/routers/music.py diff --git a/.gitignore b/.gitignore index 17098c1..eaf5237 100644 --- a/.gitignore +++ b/.gitignore @@ -24,11 +24,5 @@ _css_ok.txt _audit_work/ .zcode/ .env -新会话引导.md -交接文档.md -修复验收台账_F01-F117.md -待修问题清单_三轮汇总.md -待修问题清单_第四轮深挖.md -待修问题清单_第五轮深挖.md _smoke_test_downloader.py 云村小助手-网站开关.bat diff --git a/core/repo.py b/core/repo.py index 1c264e3..655f27f 100644 --- a/core/repo.py +++ b/core/repo.py @@ -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 +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(只读对账) ---------- async def ledger_open_count() -> int: diff --git a/core/session.py b/core/session.py new file mode 100644 index 0000000..c09c404 --- /dev/null +++ b/core/session.py @@ -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 diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..307f790 --- /dev/null +++ b/tests/test_session.py @@ -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 diff --git a/web/app.py b/web/app.py index 4057354..8e45678 100644 --- a/web/app.py +++ b/web/app.py @@ -18,6 +18,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI 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 system as system_router @@ -48,6 +50,9 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI: # 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比) 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(music_router.router, prefix='/api/v1', tags=['music']) + + install_error_handlers(app) if serve_flask_fallback: _mount_flask_fallback(app) diff --git a/web/deps.py b/web/deps.py new file mode 100644 index 0000000..53b1871 --- /dev/null +++ b/web/deps.py @@ -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 diff --git a/web/errors.py b/web/errors.py new file mode 100644 index 0000000..aed8c8a --- /dev/null +++ b/web/errors.py @@ -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) diff --git a/web/ratelimit.py b/web/ratelimit.py new file mode 100644 index 0000000..344a24a --- /dev/null +++ b/web/ratelimit.py @@ -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) diff --git a/web/routers/music.py b/web/routers/music.py new file mode 100644 index 0000000..a7994dd --- /dev/null +++ b/web/routers/music.py @@ -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, '搜索完成')