fix(douban): preserve recommendation lists and page model contracts

This commit is contained in:
jxxghp
2026-10-18 15:08:37 +08:00
parent 9e82e476d2
commit 60d2fe418a
6 changed files with 110 additions and 37 deletions

View File

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

View File

@@ -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 开始的条目偏移量,不是页码

View File

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

View File

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

View File

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

View File

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