From 00607bf5b35dbfe8d98b2502f4a24d18e047b948 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 13 Aug 2026 05:03:38 +0800 Subject: [PATCH] fix(api): restore recommendation response compatibility --- app/api/endpoints/mfa.py | 11 ++++- app/core/event.py | 4 +- app/schemas/context.py | 4 +- app/schemas/event.py | 35 ++++++++++++++- tests/test_event_plugin_errors.py | 62 ++++++++++++++++++++++++++ tests/test_explicit_response_models.py | 26 +++++++++++ tests/test_login_mfa_methods.py | 56 +++++++++++++++++++++++ tests/test_media_response_models.py | 31 +++++++++++++ 8 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 tests/test_event_plugin_errors.py diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 1a3fd40a3..f55a4457b 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -3,6 +3,7 @@ MFA (Multi-Factor Authentication) API 端点 包含 OTP 和 PassKey 相关功能 """ +import json from datetime import timedelta from typing import Any, Annotated, Optional @@ -246,7 +247,10 @@ def passkey_register_start( ) return schemas.Response( success=True, - data={"options": options_json, "transaction_token": transaction_token}, + data={ + "options": json.loads(options_json), + "transaction_token": transaction_token, + }, ) except Exception as e: logger.error(f"生成PassKey注册选项失败: {e}") @@ -358,7 +362,10 @@ def passkey_authenticate_start( ) return schemas.Response( success=True, - data={"options": options_json, "transaction_token": transaction_token}, + data={ + "options": json.loads(options_json), + "transaction_token": transaction_token, + }, ) except Exception as e: logger.error(f"生成PassKey认证选项失败: {e}") diff --git a/app/core/event.py b/app/core/event.py index 9186902a9..323b633ed 100644 --- a/app/core/event.py +++ b/app/core/event.py @@ -544,7 +544,7 @@ class EventManager(metaclass=Singleton): try: method(event) except Exception as e: - self.__handle_event_error(event=event, module_name=plugin.name, + self.__handle_event_error(event=event, module_name=plugin.get_name(), class_name=class_name, method_name=method_name, e=e) elif class_name in module_manager.get_module_ids(): # 模块处理器 @@ -640,7 +640,7 @@ class EventManager(metaclass=Singleton): # 插件同步函数在异步环境中运行,避免阻塞 await run_in_threadpool(method, event) except Exception as e: - self.__handle_event_error(event=event, module_name=plugin.name, + self.__handle_event_error(event=event, module_name=plugin.get_name(), class_name=class_name, method_name=method_name, e=e) async def __invoke_module_method_async(self, handler: Any, class_name: str, method_name: str, event: Event): diff --git a/app/schemas/context.py b/app/schemas/context.py index c8c136efe..a71485f66 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -276,9 +276,9 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel): # 别名和译名 names: Optional[list[str]] = Field(default_factory=list) # 演员 - actors: Optional[list[MediaCredit]] = Field(default_factory=list) + actors: Optional[list[Union[MediaCredit, str]]] = Field(default_factory=list) # 导演 - directors: Optional[list[MediaCredit]] = Field(default_factory=list) + directors: Optional[list[Union[MediaCredit, str]]] = Field(default_factory=list) # 详情链接 detail_link: Optional[str] = None # 其它TMDB属性 diff --git a/app/schemas/event.py b/app/schemas/event.py index a79458579..bdae7cae6 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -479,11 +479,16 @@ class TransferOverwriteCheckEventData(ChainEventData): class DiscoverMediaSource(BaseModel): """ - 探索媒体数据源的基类 + 探索媒体数据源的基类。 + + ``mediaid_prefix`` 是既有插件与前端标签使用的稳定标识; + ``media_source`` 是新的规范媒体来源。模型同时输出两者,并在输入时互相补齐, + 以兼容尚未升级的已安装插件。 """ name: str = Field(..., description="数据源名称") media_source: MediaSource = Field(..., description="媒体来源枚举") + mediaid_prefix: str = Field(..., description="兼容插件使用的媒体ID前缀") api_path: str = Field(..., description="媒体数据源API地址") filter_params: Optional[Dict[str, JsonData]] = Field( default=None, description="过滤参数" @@ -493,6 +498,34 @@ class DiscoverMediaSource(BaseModel): default=None, description="UI依赖关系字典" ) + @model_validator(mode="before") + @classmethod + def normalize_media_identity(cls, value: Any) -> Any: + """在旧前缀与规范媒体来源之间双向补齐发现源身份。""" + if not isinstance(value, dict): + return value + normalized = dict(value) + media_source = normalized.get("media_source") + mediaid_prefix = normalized.get("mediaid_prefix") + if media_source and not mediaid_prefix: + normalized["mediaid_prefix"] = str(media_source) + elif mediaid_prefix and not media_source: + normalized["media_source"] = cls._media_source_from_prefix( + str(mediaid_prefix) + ) + return normalized + + @staticmethod + def _media_source_from_prefix(mediaid_prefix: str) -> MediaSource: + """将旧插件使用的历史前缀映射为规范媒体来源枚举。""" + aliases = { + "mangguo": MediaSource.MangoTV, + "tencentvideo": MediaSource.TencentVideo, + } + if mediaid_prefix in aliases: + return aliases[mediaid_prefix] + return MediaSource(mediaid_prefix) + class DiscoverSourceEventData(ChainEventData): """ diff --git a/tests/test_event_plugin_errors.py b/tests/test_event_plugin_errors.py new file mode 100644 index 000000000..faea124d1 --- /dev/null +++ b/tests/test_event_plugin_errors.py @@ -0,0 +1,62 @@ +"""插件事件异常处理的回归测试。""" + +import pytest + +from app.core.event import Event, EventManager +from app.core.plugin import PluginManager +from app.schemas.types import ChainEventType + + +class FailingDiscoverPlugin: + """模拟只实现公开名称接口且事件处理失败的插件。""" + + @staticmethod + def get_name() -> str: + """返回插件显示名称。""" + return "测试发现插件" + + @staticmethod + def handle(_event: Event) -> None: + """模拟插件事件处理失败。""" + raise RuntimeError("discover failed") + + +@pytest.mark.asyncio +async def test_plugin_event_error_uses_public_display_name(monkeypatch): + """同步和异步调度都应通过 get_name 获取插件名称并保留原始异常。""" + event_manager = EventManager() + plugin_manager = PluginManager() + plugin = FailingDiscoverPlugin() + errors: list[dict] = [] + monkeypatch.setattr( + plugin_manager, + "_plugins", + {FailingDiscoverPlugin.__name__: FailingDiscoverPlugin}, + ) + monkeypatch.setattr( + plugin_manager, + "_running_plugins", + {FailingDiscoverPlugin.__name__: plugin}, + ) + monkeypatch.setattr( + event_manager, + "_EventManager__handle_event_error", + lambda **kwargs: errors.append(kwargs), + ) + event = Event(ChainEventType.DiscoverSource) + + event_manager._EventManager__invoke_handler_by_type_sync( + FailingDiscoverPlugin.handle, event + ) + await event_manager._EventManager__invoke_plugin_method_async( + plugin_manager, + FailingDiscoverPlugin.__name__, + "handle", + event, + ) + + assert [error["module_name"] for error in errors] == [ + "测试发现插件", + "测试发现插件", + ] + assert all(str(error["e"]) == "discover failed" for error in errors) diff --git a/tests/test_explicit_response_models.py b/tests/test_explicit_response_models.py index bea7fafb3..bb4c32e8b 100644 --- a/tests/test_explicit_response_models.py +++ b/tests/test_explicit_response_models.py @@ -1,5 +1,6 @@ """API 响应中稳定集合与动态 JSON 字段的模型契约测试。""" +from app.schemas.event import DiscoverMediaSource from app.schemas.file import FileItem, StorageTransType from app.schemas.mediaserver import MediaServerLibrary, MediaServerPlayItem, NotExistMediaInfo from app.schemas.plugin import Plugin, PluginDashboard @@ -7,6 +8,7 @@ from app.schemas.site import SiteStatistic, SiteUserData from app.schemas.subscribe import Subscribe from app.schemas.tmdb import TmdbEpisode from app.schemas.token import Token +from app.schemas.types import MediaSource from app.schemas.user import User @@ -122,3 +124,27 @@ def test_collection_json_schemas_define_items_or_tuple_members(): for branch in messages_schema["items"]["anyOf"] ) assert crew_schema["items"]["$ref"].endswith("/TmdbEpisodeCrew") + + +def test_discover_media_source_keeps_legacy_prefix_compatible(): + """发现源应兼容旧插件前缀,并同时输出规范媒体来源。""" + legacy = DiscoverMediaSource( + name="哔哩哔哩", + mediaid_prefix="bilibili", + api_path="plugin/BilibiliDiscover/discover", + ) + current = DiscoverMediaSource( + name="腾讯视频", + media_source=MediaSource.TencentVideo, + api_path="plugin/TencentVideoDiscover/discover", + ) + historical_alias = DiscoverMediaSource( + name="芒果 TV", + mediaid_prefix="mangguo", + api_path="plugin/MangoTVDiscover/discover", + ) + + assert legacy.media_source is MediaSource.Bilibili + assert legacy.model_dump(mode="json")["mediaid_prefix"] == "bilibili" + assert current.mediaid_prefix == MediaSource.TencentVideo.value + assert historical_alias.media_source is MediaSource.MangoTV diff --git a/tests/test_login_mfa_methods.py b/tests/test_login_mfa_methods.py index 1735c1ded..21b49a537 100644 --- a/tests/test_login_mfa_methods.py +++ b/tests/test_login_mfa_methods.py @@ -6,7 +6,9 @@ from fastapi import HTTPException from starlette.requests import Request from starlette.responses import Response +from app import schemas from app.api.endpoints import login as login_endpoint +from app.api.endpoints import mfa as mfa_endpoint from app.chain.user import MfaRequired, UserChain @@ -116,3 +118,57 @@ def test_wallpaper_returns_url_in_data(monkeypatch): assert response.data == "https://images.example/wallpaper.jpg" assert response.message == "" assert not hasattr(response, "message_i18n") + + +def test_passkey_authentication_start_returns_object_options(monkeypatch): + """Passkey 认证选项应作为对象返回,避免统一响应模型校验失败。""" + monkeypatch.setattr( + mfa_endpoint.PassKeyHelper, + "generate_authentication_options", + staticmethod( + lambda **_: ('{"challenge":"auth-challenge","timeout":60000}', "challenge") + ), + ) + monkeypatch.setattr( + mfa_endpoint.PasskeyChallengeStore, + "issue", + staticmethod(lambda **_: "authentication-transaction"), + ) + + response = mfa_endpoint.passkey_authenticate_start( + mfa_endpoint.PassKeyAuthenticationStart() + ) + payload = schemas.PasskeyStartData.model_validate(response.data) + + assert response.success is True + assert payload.options.root["challenge"] == "auth-challenge" + assert payload.transaction_token == "authentication-transaction" + + +def test_passkey_registration_start_returns_object_options(monkeypatch): + """Passkey 注册选项应作为对象返回,避免统一响应模型校验失败。""" + monkeypatch.setattr( + mfa_endpoint.PassKey, + "get_by_user_id", + staticmethod(lambda **_: []), + ) + monkeypatch.setattr( + mfa_endpoint.PassKeyHelper, + "generate_registration_options", + staticmethod( + lambda **_: ('{"challenge":"register-challenge"}', "challenge") + ), + ) + monkeypatch.setattr( + mfa_endpoint.PasskeyChallengeStore, + "issue", + staticmethod(lambda **_: "registration-transaction"), + ) + user = SimpleNamespace(id=1, name="user", settings={}) + + response = mfa_endpoint.passkey_register_start(current_user=user) + payload = schemas.PasskeyStartData.model_validate(response.data) + + assert response.success is True + assert payload.options.root["challenge"] == "register-challenge" + assert payload.transaction_token == "registration-transaction" diff --git a/tests/test_media_response_models.py b/tests/test_media_response_models.py index 7e6bfabaf..664cdab19 100644 --- a/tests/test_media_response_models.py +++ b/tests/test_media_response_models.py @@ -48,3 +48,34 @@ def test_media_search_response_preserves_core_collection_fields() -> None: assert "douban_info" in result assert "bangumi_info" in result assert "anilist_info" in result + + +def test_media_response_accepts_cross_source_credit_shapes() -> None: + """媒体响应应同时保留豆瓣姓名字符串和 TMDB 演职员对象。""" + router = ResponseAPIRouter() + + @router.get("/recommend", response_model=list[schemas.MediaInfo]) + def recommend_media() -> list[dict]: + """返回跨来源的演职员字段形态。""" + return [ + { + "media_source": MediaSource.Douban, + "media_id": "1292052", + "title": "示例电影", + "actors": ["演员甲", {"id": 2, "name": "演员乙"}], + "directors": ["导演甲", {"id": 3, "name": "导演乙"}], + } + ] + + app = FastAPI() + app.include_router(router) + response = TestClient(app).get("/recommend") + + assert response.status_code == 200 + media = response.json()["data"][0] + assert media["actors"][0] == "演员甲" + assert media["actors"][1]["id"] == 2 + assert media["actors"][1]["name"] == "演员乙" + assert media["directors"][0] == "导演甲" + assert media["directors"][1]["id"] == 3 + assert media["directors"][1]["name"] == "导演乙"