From ab9eeedb3ed2bc7172c944a980d2be868b9ec182 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 13 Jun 2026 08:09:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B7=B3=E8=BF=87=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E7=A9=BA=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/recommend.py | 48 +++++++-------- tests/test_recommend_chain.py | 111 +++++++++++++++++++++++++--------- 2 files changed, 106 insertions(+), 53 deletions(-) diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 89d3ae86..ddec89cd 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -105,7 +105,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): ImageHelper().fetch_image(url=url) @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def tmdb_movies(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", @@ -131,7 +131,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [movie.to_dict() for movie in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "zh|en|ja|ko", @@ -166,7 +166,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [info.to_dict() for info in infos] if infos else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ Bangumi每日放送 @@ -175,7 +175,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣正在热映 @@ -184,7 +184,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ @@ -195,7 +195,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ @@ -206,7 +206,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣电影TOP250 @@ -215,7 +215,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣国产剧集榜 @@ -224,7 +224,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣全球剧集榜 @@ -233,7 +233,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣热门动漫 @@ -242,7 +242,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣热门电影 @@ -251,7 +251,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 豆瓣热门电视剧 @@ -260,7 +260,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_tmdb_movies(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "", @@ -286,7 +286,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [movie.to_dict() for movie in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", with_genres: Optional[str] = "", with_original_language: Optional[str] = "zh|en|ja|ko", @@ -321,7 +321,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [info.to_dict() for info in infos] if infos else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步Bangumi每日放送 @@ -330,7 +330,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣正在热映 @@ -339,7 +339,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ @@ -350,7 +350,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "", page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ @@ -361,7 +361,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣电影TOP250 @@ -370,7 +370,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣国产剧集榜 @@ -379,7 +379,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣全球剧集榜 @@ -388,7 +388,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣热门动漫 @@ -397,7 +397,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in tvs] if tvs else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣热门电影 @@ -406,7 +406,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [media.to_dict() for media in movies] if movies else [] @log_execution_time(logger=logger) - @cached(ttl=recommend_ttl, region=recommend_cache_region) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """ 异步豆瓣热门电视剧 diff --git a/tests/test_recommend_chain.py b/tests/test_recommend_chain.py index b5a81f6d..165e23cb 100644 --- a/tests/test_recommend_chain.py +++ b/tests/test_recommend_chain.py @@ -1,42 +1,95 @@ import asyncio -from unittest import TestCase +from typing import Generator from unittest.mock import AsyncMock, patch +import pytest + from app.chain.recommend import RecommendChain from app.core.cache import TTLCache -class RecommendChainTest(TestCase): - def tearDown(self): - """ - 清理推荐缓存,避免缓存装饰器状态影响其他用例。 - """ - RecommendChain.tmdb_trending.cache_clear() - asyncio.run(RecommendChain.async_tmdb_trending.cache_clear()) - TTLCache(region=RecommendChain.recommend_cache_region).clear() +SYNC_EMPTY_CACHE_CASES = [ + ("tmdb_movies", "app.chain.recommend.TmdbChain", "tmdb_discover"), + ("tmdb_tvs", "app.chain.recommend.TmdbChain", "tmdb_discover"), + ("tmdb_trending", "app.chain.recommend.TmdbChain", "tmdb_trending"), + ("bangumi_calendar", "app.chain.recommend.BangumiChain", "calendar"), + ("douban_movie_showing", "app.chain.recommend.DoubanChain", "movie_showing"), + ("douban_movies", "app.chain.recommend.DoubanChain", "douban_discover"), + ("douban_tvs", "app.chain.recommend.DoubanChain", "douban_discover"), + ("douban_movie_top250", "app.chain.recommend.DoubanChain", "movie_top250"), + ("douban_tv_weekly_chinese", "app.chain.recommend.DoubanChain", "tv_weekly_chinese"), + ("douban_tv_weekly_global", "app.chain.recommend.DoubanChain", "tv_weekly_global"), + ("douban_tv_animation", "app.chain.recommend.DoubanChain", "tv_animation"), + ("douban_movie_hot", "app.chain.recommend.DoubanChain", "movie_hot"), + ("douban_tv_hot", "app.chain.recommend.DoubanChain", "tv_hot"), +] - def test_tmdb_trending_does_not_cache_empty_result(self): - """ - TMDB流行趋势返回空列表时不应缓存,避免一次接口异常后长时间固定为空。 - """ - chain = RecommendChain() - with patch("app.chain.recommend.TmdbChain") as tmdb_chain: - tmdb_chain.return_value.tmdb_trending.side_effect = [[], []] +ASYNC_EMPTY_CACHE_CASES = [ + ("async_tmdb_movies", "app.chain.recommend.TmdbChain"), + ("async_tmdb_tvs", "app.chain.recommend.TmdbChain"), + ("async_tmdb_trending", "app.chain.recommend.TmdbChain"), + ("async_bangumi_calendar", "app.chain.recommend.BangumiChain"), + ("async_douban_movie_showing", "app.chain.recommend.DoubanChain"), + ("async_douban_movies", "app.chain.recommend.DoubanChain"), + ("async_douban_tvs", "app.chain.recommend.DoubanChain"), + ("async_douban_movie_top250", "app.chain.recommend.DoubanChain"), + ("async_douban_tv_weekly_chinese", "app.chain.recommend.DoubanChain"), + ("async_douban_tv_weekly_global", "app.chain.recommend.DoubanChain"), + ("async_douban_tv_animation", "app.chain.recommend.DoubanChain"), + ("async_douban_movie_hot", "app.chain.recommend.DoubanChain"), + ("async_douban_tv_hot", "app.chain.recommend.DoubanChain"), +] - self.assertEqual(chain.tmdb_trending(page=1), []) - self.assertEqual(chain.tmdb_trending(page=1), []) - self.assertEqual(tmdb_chain.return_value.tmdb_trending.call_count, 2) +def clear_recommend_cache() -> None: + """清理推荐缓存,避免缓存装饰器状态影响用例。""" + TTLCache(region=RecommendChain.recommend_cache_region).clear() - def test_async_tmdb_trending_does_not_cache_empty_result(self): - """ - 异步TMDB流行趋势返回空列表时也不应缓存。 - """ - chain = RecommendChain() - with patch("app.chain.recommend.TmdbChain") as tmdb_chain: - tmdb_chain.return_value.async_run_module = AsyncMock(side_effect=[[], []]) - self.assertEqual(asyncio.run(chain.async_tmdb_trending(page=1)), []) - self.assertEqual(asyncio.run(chain.async_tmdb_trending(page=1)), []) +@pytest.fixture(autouse=True) +def isolated_recommend_cache() -> Generator[None, None, None]: + """每个用例前后都清空推荐缓存。""" + clear_recommend_cache() + yield + clear_recommend_cache() - self.assertEqual(tmdb_chain.return_value.async_run_module.call_count, 2) + +@pytest.mark.parametrize( + ("method_name", "chain_target", "backend_method"), + SYNC_EMPTY_CACHE_CASES, +) +def test_sync_recommend_methods_do_not_cache_empty_result( + method_name: str, + chain_target: str, + backend_method: str, +) -> None: + """同步推荐来源返回空列表时不应缓存。""" + chain = RecommendChain() + recommend_method = getattr(chain, method_name) + + with patch(chain_target) as backend_chain: + backend_call = getattr(backend_chain.return_value, backend_method) + backend_call.side_effect = [[], []] + + assert recommend_method(page=1) == [] + assert recommend_method(page=1) == [] + + assert backend_call.call_count == 2 + + +@pytest.mark.parametrize(("method_name", "chain_target"), ASYNC_EMPTY_CACHE_CASES) +def test_async_recommend_methods_do_not_cache_empty_result( + method_name: str, + chain_target: str, +) -> None: + """异步推荐来源返回空列表时不应缓存。""" + chain = RecommendChain() + recommend_method = getattr(chain, method_name) + + with patch(chain_target) as backend_chain: + backend_chain.return_value.async_run_module = AsyncMock(side_effect=[[], []]) + + assert asyncio.run(recommend_method(page=1)) == [] + assert asyncio.run(recommend_method(page=1)) == [] + + assert backend_chain.return_value.async_run_module.call_count == 2