fix(api): restore recommendation response compatibility

This commit is contained in:
jxxghp
2026-08-13 05:03:38 +08:00
parent e5b52dc0a8
commit 00607bf5b3
8 changed files with 222 additions and 7 deletions

View File

@@ -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}")

View File

@@ -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):

View File

@@ -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属性

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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"] == "导演乙"