From 60d2fe418a7cc20f0887248b7cd3009dbc508271 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 18 Oct 2026 15:08:37 +0800 Subject: [PATCH] fix(douban): preserve recommendation lists and page model contracts --- app/modules/douban/__init__.py | 33 +++++----- app/modules/douban/apiv2.py | 16 ++--- app/runtime/extensions/module/contracts.py | 16 +++-- .../runtime-contract-baseline.json | 16 ++--- tests/test_douban_comment_projection.py | 5 ++ tests/test_douban_recommendations.py | 61 +++++++++++++++++++ 6 files changed, 110 insertions(+), 37 deletions(-) create mode 100644 tests/test_douban_recommendations.py diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index afdf64c3c..7255dfc50 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -2015,10 +2015,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ recommend = self.doubanapi.movie_recommendations(subject_id=doubanid) - if recommend: - items = recommend.get("items") or [] - return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)] - return [] + return self._process_recommendations(recommend) def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ @@ -2028,10 +2025,20 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ recommend = self.doubanapi.tv_recommendations(subject_id=doubanid) - if recommend: - items = recommend.get("items") or [] - return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)] - return [] + return self._process_recommendations(recommend) + + @staticmethod + def _process_recommendations( + recommend: Optional[Union[dict[str, Any], list[dict[str, Any]]]], + ) -> List[MediaInfo]: + """ + 将豆瓣推荐列表或携带 items 的响应投影为媒体列表。 + + :param recommend: 推荐接口原生列表或兼容的分页字典;无响应时为 None + :return: 保持原顺序的媒体列表,跳过非字典条目且不修改缓存响应 + """ + items = (recommend.get("items") or []) if isinstance(recommend, dict) else (recommend or []) + return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)] @staticmethod def _process_comment_author(doubaninfo: Optional[dict[str, Any]]) -> Optional[_SchemaMediaCommentAuthor]: @@ -2270,10 +2277,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ recommend = await self.doubanapi.async_movie_recommendations(subject_id=doubanid) - if recommend: - items = recommend.get("items") or [] - return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)] - return [] + return self._process_recommendations(recommend) async def async_douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ @@ -2283,10 +2287,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase): :return: 当前页或本次查询的标准对象列表;无结果时为空列表 """ recommend = await self.doubanapi.async_tv_recommendations(subject_id=doubanid) - if recommend: - items = recommend.get("items") or [] - return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)] - return [] + return self._process_recommendations(recommend) async def async_douban_movie_interests(self, doubanid: str, start: int = 0, count: int = 20) -> _SchemaMediaCommentPage: diff --git a/app/modules/douban/apiv2.py b/app/modules/douban/apiv2.py index 9dc077be1..0cf8c0ce6 100644 --- a/app/modules/douban/apiv2.py +++ b/app/modules/douban/apiv2.py @@ -1389,9 +1389,9 @@ class DoubanApi(metaclass=WeakSingleton): start=start, count=count, _ts=ts) def movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, - ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]: + ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]: """ - 电影推荐。 + 电影推荐,保留原生列表或携带 items 的字典响应。 :param subject_id: 豆瓣条目原生标识 :param start: 从 0 开始的条目偏移量,不是页码 @@ -1403,9 +1403,9 @@ class DoubanApi(metaclass=WeakSingleton): start=start, count=count, _ts=ts) async def async_movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, - ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]: + ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]: """ - 电影推荐(异步版本)。 + 电影推荐(异步版本),保留原生列表或携带 items 的字典响应。 :param subject_id: 豆瓣条目原生标识 :param start: 从 0 开始的条目偏移量,不是页码 @@ -1417,9 +1417,9 @@ class DoubanApi(metaclass=WeakSingleton): start=start, count=count, _ts=ts) def tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, - ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]: + ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]: """ - 电视剧推荐。 + 电视剧推荐,保留原生列表或携带 items 的字典响应。 :param subject_id: 豆瓣条目原生标识 :param start: 从 0 开始的条目偏移量,不是页码 @@ -1431,9 +1431,9 @@ class DoubanApi(metaclass=WeakSingleton): start=start, count=count, _ts=ts) async def async_tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20, - ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]: + ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]: """ - 电视剧推荐(异步版本)。 + 电视剧推荐(异步版本),保留原生列表或携带 items 的字典响应。 :param subject_id: 豆瓣条目原生标识 :param start: 从 0 开始的条目偏移量,不是页码 diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index 37848005d..2d824d940 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -10,6 +10,8 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any, Protocol +from pydantic import BaseModel + class ModuleResultAggregation(StrEnum): """ @@ -32,7 +34,9 @@ class ModuleResultAggregation(StrEnum): class ModuleResultShape(StrEnum): """ - 描述模块 provider 返回值的基础 Python 形状。 + 描述模块 provider 返回值的基础 Python 形状或模型对象。 + + MODEL 用于 Pydantic 分页等模型对象,MAPPING 仍只接受字典。 """ # 接受任意结果形状 @@ -43,6 +47,7 @@ class ModuleResultShape(StrEnum): STRING = "string" # 结果应为键值映射 MAPPING = "mapping" + MODEL = "model" # 结果应为布尔值 BOOLEAN = "boolean" # 结果应为字节序列 @@ -699,7 +704,7 @@ _METHOD_CONTRACTS = { family="douban", input_contract="DoubanCommentRequest", result_contract="MediaCommentPage", - result_shape=ModuleResultShape.MAPPING, + result_shape=ModuleResultShape.MODEL, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("doubanid", "start", "count"), ), @@ -707,7 +712,7 @@ _METHOD_CONTRACTS = { family="douban", input_contract="DoubanCommentRequest", result_contract="MediaCommentPage", - result_shape=ModuleResultShape.MAPPING, + result_shape=ModuleResultShape.MODEL, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("doubanid", "start", "count"), ), @@ -715,7 +720,7 @@ _METHOD_CONTRACTS = { family="douban", input_contract="DoubanCommentRequest", result_contract="MediaReviewPage", - result_shape=ModuleResultShape.MAPPING, + result_shape=ModuleResultShape.MODEL, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("doubanid", "start", "count"), ), @@ -723,7 +728,7 @@ _METHOD_CONTRACTS = { family="douban", input_contract="DoubanCommentRequest", result_contract="MediaReviewPage", - result_shape=ModuleResultShape.MAPPING, + result_shape=ModuleResultShape.MODEL, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("doubanid", "start", "count"), ), @@ -1965,6 +1970,7 @@ def diagnose_module_result(method: str, result: Any) -> tuple[str, ...]: ModuleResultShape.LIST: isinstance(result, list), ModuleResultShape.STRING: isinstance(result, str), ModuleResultShape.MAPPING: isinstance(result, dict), + ModuleResultShape.MODEL: isinstance(result, BaseModel), ModuleResultShape.BOOLEAN: isinstance(result, bool), ModuleResultShape.BYTES: isinstance(result, bytes), } diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index c85560f3e..d0447db30 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -4408,7 +4408,7 @@ "start" ], "result_contract": "MediaCommentPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4448,7 +4448,7 @@ "start" ], "result_contract": "MediaReviewPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4527,7 +4527,7 @@ "start" ], "result_contract": "MediaCommentPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4567,7 +4567,7 @@ "start" ], "result_contract": "MediaReviewPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5615,7 +5615,7 @@ "start" ], "result_contract": "MediaCommentPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5655,7 +5655,7 @@ "start" ], "result_contract": "MediaReviewPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5734,7 +5734,7 @@ "start" ], "result_contract": "MediaCommentPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5774,7 +5774,7 @@ "start" ], "result_contract": "MediaReviewPage", - "result_shape": "mapping", + "result_shape": "model", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", diff --git a/tests/test_douban_comment_projection.py b/tests/test_douban_comment_projection.py index 7b67d1f41..ebbe2c5f2 100644 --- a/tests/test_douban_comment_projection.py +++ b/tests/test_douban_comment_projection.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, Mock import pytest from app.modules.douban import DoubanModule +from app.runtime.extensions.module.contracts import diagnose_module_result def _module() -> DoubanModule: @@ -180,6 +181,10 @@ def test_sync_and_async_entries_share_projection( async_result = asyncio.run(getattr(module, async_method)("100", start=5, count=10)) assert sync_result == async_result + assert diagnose_module_result(sync_method, sync_result) == () + assert diagnose_module_result(async_method, async_result) == () + assert diagnose_module_result(sync_method, {}) == ("unexpected-result:model:dict",) + assert diagnose_module_result(async_method, []) == ("unexpected-result:model:list",) getattr(module.doubanapi, client_method).assert_called_once_with( subject_id="100", start=5, count=10 ) diff --git a/tests/test_douban_recommendations.py b/tests/test_douban_recommendations.py new file mode 100644 index 000000000..bca122333 --- /dev/null +++ b/tests/test_douban_recommendations.py @@ -0,0 +1,61 @@ +""" +豆瓣推荐从 HTTP 响应到媒体投影的同步异步回归。 +""" + +import asyncio +from copy import deepcopy +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.modules.douban import DoubanModule +from app.modules.douban.apiv2 import DoubanApi + + +@pytest.mark.parametrize("kind", ["movie", "tv"]) +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("shape", ["list", "mapping", "empty_list", "empty_mapping", "missing"]) +def test_recommendations_preserve_native_lists_and_compatible_mappings( + monkeypatch: pytest.MonkeyPatch, kind: str, async_mode: bool, shape: str, +) -> None: + """ + 实际客户端解码列表和分页字典后,四个模块入口均保留推荐顺序且不污染缓存。 + """ + records = [ + {"id": "100", "type": kind, "title": "第一条推荐"}, + {"id": "101", "type": kind, "title": "第二条推荐"}, + ] + payloads = { + "list": records, + "mapping": {"items": records}, + "empty_list": [], + "empty_mapping": {}, + "missing": None, + } + payload = payloads[shape] + original = deepcopy(payload) + response = None if payload is None else Mock(json=Mock(return_value=payload)) + api = object.__new__(DoubanApi) + api._request = Mock(get_res=Mock(return_value=response)) + async_request = AsyncMock(return_value=response) + monkeypatch.setattr( + "app.modules.douban.apiv2.AsyncRequestUtils", + Mock(return_value=Mock(get_res=async_request)), + ) + module = object.__new__(DoubanModule) + module.doubanapi = api + method = f"{'async_' if async_mode else ''}douban_{kind}_recommend" + api.clear_cache() + try: + result = getattr(module, method)("42") + result = asyncio.run(result) if async_mode else result + expected_ids = ["100", "101"] if shape in {"list", "mapping"} else [] + expected_titles = [record["title"] for record in records] if expected_ids else [] + assert [media.douban_id for media in result] == expected_ids + assert [media.title for media in result] == expected_titles + assert payload == original + request = async_request if async_mode else api._request.get_res + request.assert_called_once() + assert request.call_args.kwargs["url"].endswith(f"/{kind}/42/recommendations") + finally: + api.clear_cache()