From 96ef431efc535caa4e0cc1203713efea0db74a0c Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 13 Jul 2026 12:33:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=B1=86=E7=93=A3?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/douban.py | 50 ++++++++++ app/locales/en-US.json | 3 + app/locales/zh-CN.json | 5 +- app/locales/zh-TW.json | 3 + app/modules/douban/douban_cache.py | 29 +++++- tests/test_douban_cache_management.py | 136 ++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 tests/test_douban_cache_management.py diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 06f12429..cc8e6081 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -6,11 +6,61 @@ from app import schemas from app.chain.douban import DoubanChain from app.core.context import MediaInfo from app.core.security import verify_token +from app.db.models.user import User +from app.db.user_oper import get_current_active_superuser_async +from app.modules.douban.douban_cache import DoubanCache from app.schemas import MediaType router = APIRouter() +@router.get( + "/cache", summary="查询豆瓣识别缓存", response_model=schemas.Response +) +async def douban_recognition_cache( + _: User = Depends(get_current_active_superuser_async), +) -> schemas.Response: + """查询可管理的豆瓣识别缓存。""" + cache_items = DoubanCache().list_items() + recognized_count = sum(1 for item in cache_items if item["douban_id"]) + return schemas.Response( + success=True, + data={ + "count": len(cache_items), + "recognized": recognized_count, + "unrecognized": len(cache_items) - recognized_count, + "data": cache_items, + }, + ) + + +@router.delete( + "/cache/{cache_key:path}", + summary="删除指定豆瓣识别缓存", + response_model=schemas.Response, +) +async def delete_douban_recognition_cache( + cache_key: str, + _: User = Depends(get_current_active_superuser_async), +) -> schemas.Response: + """按缓存键删除单条豆瓣识别缓存。""" + deleted_item = DoubanCache().delete(cache_key) + if not deleted_item: + return schemas.Response(success=False, message="豆瓣识别缓存不存在") + return schemas.Response(success=True, message="豆瓣识别缓存删除成功") + + +@router.delete( + "/cache", summary="清空豆瓣识别缓存", response_model=schemas.Response +) +async def clear_douban_recognition_cache( + _: User = Depends(get_current_active_superuser_async), +) -> schemas.Response: + """清空全部豆瓣识别缓存。""" + DoubanCache().clear() + return schemas.Response(success=True, message="豆瓣识别缓存清理完成") + + @router.get( "/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson ) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 3d8be29a..6ff3b194 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -179,6 +179,9 @@ "TheMovieDb 识别缓存不存在": "TheMovieDb recognition cache does not exist", "TheMovieDb 识别缓存删除成功": "TheMovieDb recognition cache deleted successfully", "TheMovieDb 识别缓存清理完成": "TheMovieDb recognition cache cleanup completed", + "豆瓣识别缓存不存在": "Douban recognition cache does not exist", + "豆瓣识别缓存删除成功": "Douban recognition cache deleted successfully", + "豆瓣识别缓存清理完成": "Douban recognition cache cleanup completed", "重新识别完成": "Re-recognition completed", "未识别到新名称": "Unable to recognize new name", "缺少参数": "Missing parameters", diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json index b4575616..d4615c19 100644 --- a/app/locales/zh-CN.json +++ b/app/locales/zh-CN.json @@ -108,7 +108,10 @@ "Redis连接失败,请检查配置": "Redis连接失败,请检查配置", "TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在", "TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功", - "TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成" + "TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成", + "豆瓣识别缓存不存在": "豆瓣识别缓存不存在", + "豆瓣识别缓存删除成功": "豆瓣识别缓存删除成功", + "豆瓣识别缓存清理完成": "豆瓣识别缓存清理完成" }, "message_patterns": [ { diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index b27837d2..e65d8aee 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -179,6 +179,9 @@ "TheMovieDb 识别缓存不存在": "TheMovieDb 識別快取不存在", "TheMovieDb 识别缓存删除成功": "TheMovieDb 識別快取刪除成功", "TheMovieDb 识别缓存清理完成": "TheMovieDb 識別快取清理完成", + "豆瓣识别缓存不存在": "豆瓣識別快取不存在", + "豆瓣识别缓存删除成功": "豆瓣識別快取刪除成功", + "豆瓣识别缓存清理完成": "豆瓣識別快取清理完成", "重新识别完成": "重新識別完成", "未识别到新名称": "未識別到新名稱", "缺少参数": "缺少參數", diff --git a/app/modules/douban/douban_cache.py b/app/modules/douban/douban_cache.py index 52a3cb4f..d5f9ddb1 100644 --- a/app/modules/douban/douban_cache.py +++ b/app/modules/douban/douban_cache.py @@ -25,10 +25,11 @@ class DoubanCache(metaclass=WeakSingleton): "type": MediaType } """ - # TMDB缓存过期 + # 豆瓣缓存过期 _douban_cache_expire: bool = True def __init__(self): + """初始化豆瓣识别缓存并恢复本地持久化数据。""" self.maxsize = settings.CONF.douban self.ttl = settings.CONF.meta self.region = "__douban_cache__" @@ -46,6 +47,30 @@ class DoubanCache(metaclass=WeakSingleton): """ with lock: self._cache.clear() + self.save(force=True) + + def list_items(self) -> list[dict]: + """返回可供管理界面展示的豆瓣识别缓存列表。""" + with lock: + cache_items = [] + for key, value in self._cache.items(): + if not isinstance(value, dict): + continue + media_type = value.get("type") + if not isinstance(media_type, MediaType): + try: + media_type = MediaType(media_type) + except (TypeError, ValueError): + media_type = None + cache_items.append({ + "key": key, + "douban_id": value.get("id") or 0, + "title": value.get("title") or "", + "year": value.get("year") or "", + "media_type": media_type.to_agent() if media_type else "unknown", + "poster_path": value.get("poster_path") or "", + }) + return sorted(cache_items, key=lambda item: item["key"]) @staticmethod def __get_key(meta: MetaBase) -> str: @@ -73,6 +98,7 @@ class DoubanCache(metaclass=WeakSingleton): redis_data = self._cache.get(key) if redis_data: self._cache.delete(key) + self.save(force=True) return redis_data return {} @@ -169,4 +195,5 @@ class DoubanCache(metaclass=WeakSingleton): pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa def __del__(self): + """实例释放前保存非 Redis 缓存。""" self.save() diff --git a/tests/test_douban_cache_management.py b/tests/test_douban_cache_management.py new file mode 100644 index 00000000..8218749e --- /dev/null +++ b/tests/test_douban_cache_management.py @@ -0,0 +1,136 @@ +import asyncio +import inspect + +from app.api.endpoints import douban as douban_endpoint +from app.db.user_oper import get_current_active_superuser_async +from app.modules.douban.douban_cache import DoubanCache +from app.schemas.types import MediaType + + +class _MemoryCacheStub: + """提供豆瓣缓存管理测试所需的最小内存后端。""" + + def __init__(self, data: dict): + """使用给定字典初始化测试缓存。""" + self.data = data + + def items(self): + """返回全部缓存条目。""" + return self.data.items() + + def get(self, key: str): + """读取指定缓存条目。""" + return self.data.get(key) + + def delete(self, key: str): + """删除指定缓存条目。""" + self.data.pop(key, None) + + def clear(self): + """清空全部缓存条目。""" + self.data.clear() + + +def _build_douban_cache(data: dict) -> DoubanCache: + """构造绕过单例初始化的豆瓣缓存测试实例。""" + cache = object.__new__(DoubanCache) + cache._cache = _MemoryCacheStub(data) + cache.save = lambda force=False: None + return cache + + +def test_douban_cache_management_endpoints_require_superuser(): + """豆瓣识别缓存管理接口必须仅允许超级管理员访问。""" + endpoints = [ + douban_endpoint.douban_recognition_cache, + douban_endpoint.delete_douban_recognition_cache, + douban_endpoint.clear_douban_recognition_cache, + ] + + for endpoint in endpoints: + dependency = inspect.signature(endpoint).parameters["_"].default.dependency + assert dependency is get_current_active_superuser_async + + +def test_douban_cache_list_items_normalizes_media_type_and_sorting(): + """豆瓣管理列表应输出稳定顺序和前端可识别的媒体类型。""" + cache = _build_douban_cache({ + "[电视剧]Zulu-2024-1": { + "id": "2", + "title": "Zulu", + "type": MediaType.TV, + "year": "2024", + }, + "[电影]Alpha-2023-None": { + "id": "1", + "title": "Alpha", + "type": "电影", + "year": "2023", + "poster_path": "https://example.com/alpha.jpg", + }, + "[电影]Missing-2022-None": {"id": 0}, + }) + + items = cache.list_items() + + assert [item["title"] for item in items] == ["Alpha", "", "Zulu"] + assert [item["media_type"] for item in items] == ["movie", "unknown", "tv"] + assert items[0]["poster_path"] == "https://example.com/alpha.jpg" + assert items[1]["douban_id"] == 0 + + +def test_douban_cache_delete_and_clear_persist_immediately(monkeypatch): + """豆瓣管理操作应修改运行时缓存并立即触发本地持久化。""" + cache = _build_douban_cache({"first": {"id": "1"}, "second": {"id": "2"}}) + saved_forces = [] + monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force)) + + assert cache.delete("first") == {"id": "1"} + assert cache.delete("missing") == {} + cache.clear() + + assert cache.list_items() == [] + assert saved_forces == [True, True] + + +def test_douban_cache_endpoint_returns_management_statistics(monkeypatch): + """豆瓣查询接口应返回识别成功和失败条目的统计。""" + cache = _build_douban_cache({ + "recognized": {"id": "1", "title": "Alpha", "type": MediaType.MOVIE}, + "unrecognized": {"id": 0}, + }) + monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache) + + response = asyncio.run(douban_endpoint.douban_recognition_cache(None)) + + assert response.success is True + assert response.data["count"] == 2 + assert response.data["recognized"] == 1 + assert response.data["unrecognized"] == 1 + + +def test_douban_cache_delete_endpoint_reports_missing_item(monkeypatch): + """豆瓣删除接口应区分成功删除与缓存不存在。""" + cache = _build_douban_cache({"existing": {"id": "1"}}) + monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache) + + deleted_response = asyncio.run( + douban_endpoint.delete_douban_recognition_cache("existing", None) + ) + missing_response = asyncio.run( + douban_endpoint.delete_douban_recognition_cache("missing", None) + ) + + assert deleted_response.success is True + assert missing_response.success is False + + +def test_douban_cache_clear_endpoint_removes_all_items(monkeypatch): + """豆瓣清空接口应删除全部识别缓存。""" + cache = _build_douban_cache({"existing": {"id": "1"}}) + monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache) + + response = asyncio.run(douban_endpoint.clear_douban_recognition_cache(None)) + + assert response.success is True + assert cache.list_items() == []