From e9563b03467555fccec3d34966d9f84aae48d261 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 27 Oct 2026 06:05:49 +0800 Subject: [PATCH] =?UTF-8?q?perf(api):=20=E5=90=88=E5=B9=B6=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E7=8A=B6=E6=80=81=E6=9F=A5=E8=AF=A2=E5=B9=B6=E5=87=8F?= =?UTF-8?q?=E8=BD=BB=E6=8E=A8=E8=8D=90=E8=BD=AE=E8=AF=A2=20(#7207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/media.py | 85 +++++++++++++++- app/api/endpoints/search.py | 55 ++++------- app/schemas/media.py | 32 +++++- docker/nginx.template.conf | 2 + docs/architecture-overview.md | 2 +- docs/mcp-api.md | 13 +++ scripts/generate_agent_skill_docs.py | 2 +- skills/moviepilot-api/SKILL.md | 8 +- .../architecture/dependency-baseline.json | 10 +- tests/test_builtin_skill_boundaries.py | 2 +- tests/test_media_status.py | 98 +++++++++++++++++++ tests/test_search_recommend_polling.py | 69 +++++++++++++ 12 files changed, 330 insertions(+), 48 deletions(-) create mode 100644 tests/test_media_status.py create mode 100644 tests/test_search_recommend_polling.py diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 6dcdb7255..c2b0cda22 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -6,12 +6,19 @@ from pathlib import Path from typing import Annotated, Any, List, Optional, Union, cast from uuid import UUID -from fastapi import Depends, Query +from fastapi import Body, Depends, Query from pydantic import BeforeValidator from app.adapters.web.security.access import verify_apitoken, verify_token from app.api.context import get_classification_runtime -from app.api.dependencies.auth import get_current_active_manage_user, get_current_active_user +from app.api.dependencies.auth import ( + get_current_active_manage_user, + get_current_active_user, + get_current_active_user_async, +) +from app.api.dependencies.history import get_mediaserver_query_service +from app.api.dependencies.subscription import get_subscription_query_service +from app.api.principal import ApiPrincipal from app.api.response import ( CompatibleCountParam, CompatiblePageParam, @@ -19,7 +26,9 @@ from app.api.response import ( ) from app.application.classification.runtime import ClassificationRuntime from app.application.configuration import get_api_runtime_config_snapshot +from app.application.mediaserver import MediaServerQueryService from app.application.module import get_module_manager +from app.application.subscription.query import SubscriptionQueryService from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain from app.chain.tmdb import TmdbChain @@ -39,7 +48,7 @@ from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults from app.schemas.context import MediaSeason as _SchemaMediaSeason from app.schemas.event import MediaSourceInfo as _SchemaMediaSourceInfo from app.schemas.file import FileItem as _SchemaFileItem -from app.schemas.media import normalize_media_source, resolve_media_identity +from app.schemas.media import MediaStatusQuery, MediaStatusResult, normalize_media_source, resolve_media_identity from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType, MusicEntityType @@ -47,6 +56,76 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaS router = ResponseAPIRouter() +@router.post("/status", summary="批量查询本地入库与订阅状态", response_model=list[MediaStatusResult]) +async def media_status( + items: Annotated[list[MediaStatusQuery], Body(min_length=1, max_length=50, description="按展示顺序提交的媒体,最多 50 项")], + library: MediaServerQueryService = Depends(get_mediaserver_query_service), + subscriptions: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), +) -> list[MediaStatusResult]: + """ + 在单次请求内查询卡片状态,顺序复用请求级数据库会话,不访问外部媒体服务器。 + + :param items: 当前可见卡片的身份与匹配元数据,最多 50 项 + :param library: 本地媒体库查询服务 + :param subscriptions: 订阅查询服务 + :param current_user: 当前用户,普通用户只能看到自己的订阅 + :return: 与输入同序的入库标识、订阅状态及同来源各季模式,不返回完整订阅 + """ + return [await _query_media_status(item, library, subscriptions, current_user) for item in items] + + +async def _query_media_status( + item: MediaStatusQuery, + library: MediaServerQueryService, + subscriptions: SubscriptionQueryService, + current_user: ApiPrincipal, +) -> MediaStatusResult: + """ + 保持既有标题、年份、季号回退与 owner 限制,并投影单张卡片需要的轻量字段。 + + :param item: 单个媒体的稳定身份与回退元数据 + :param library: 只访问本地数据库的媒体库查询服务 + :param subscriptions: 订阅查询服务 + :param current_user: 决定订阅 owner 范围的当前主体 + :return: 单个媒体的轻量状态,不返回他人订阅或完整记录 + """ + metadata = MetaInfo(item.title) if item.title else None + title = metadata.name if metadata else None + season = item.season if item.season is not None else metadata.begin_season if metadata else None + item_id = None + if item.mtype != MediaType.MUSIC: + item_id = await library.find_item_id( + title=title, year=item.year, mtype=item.mtype.value if item.mtype else None, + media_source=item.media_source, media_id=item.media_id, season=season, + ) + candidates = [] + if item.media_source and item.media_id: + candidates = await subscriptions.list_by_media_identity(item.media_source, item.media_id, item.music_type) + accessible = [ + record for record in candidates + if current_user.is_superuser or (bool(record.username) and record.username == current_user.name) + ] + matched = [record for record in accessible if season is None or record.season == season] + if not matched and title and item.mtype in (MediaType.MOVIE, MediaType.TV): + fallback = await subscriptions.list_by_video_metadata( + title=title, year=item.year, media_type=item.mtype, season=season, + ) + matched = [ + record for record in fallback + if current_user.is_superuser or (bool(record.username) and record.username == current_user.name) + ] + return MediaStatusResult( + item_id=item_id, + subscribed=bool(matched), + seasons={ + record.season: "normal" if not record.best_version else "best_version_full" if record.best_version_full else "best_version" + for record in accessible + if record.type == MediaType.TV.value and record.season is not None + }, + ) + + _BUILTIN_MEDIA_SOURCES = ( _SchemaMediaSourceInfo(name="TheMovieDb", media_source=MediaSource.TMDB), _SchemaMediaSourceInfo(name="豆瓣", media_source=MediaSource.Douban), diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index 829757f88..44a278a70 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -817,57 +817,40 @@ async def recommend_search_results( _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ - AI推荐资源 - 轮询接口 - 前端轮询此接口,发送筛选后的索引(如果有筛选) - 后端根据请求变化自动取消旧任务并启动新任务 + 启动 AI 推荐或只读查询当前状态,禁用和只读状态无需读取搜索结果缓存。 - 参数: - - filtered_indices: 筛选后的索引列表(可选,为空或不提供时使用所有结果) - - check_only: 仅检查状态(首次打开页面时使用,避免触发不必要的重新推理) - - force: 强制重新推荐(清除旧结果并重新启动) - - 返回数据结构: - { - "success": bool, - "message": string, // 错误信息(仅在错误时存在) - "data": { - "status": string, // 状态: disabled | idle | running | completed | error - "results": array // 推荐结果(仅status=completed时存在) - } - } + :param filtered_indices: 可选资源索引,省略时推荐当前全部候选 + :param check_only: 仅读取状态,不启动或取消任务,也不反序列化搜索缓存 + :param force: 清除旧状态并启动新任务,优先于 check_only + :param _: 已验证的访问令牌 + :return: 推荐状态与已完成结果索引;推荐失败在 message 中返回原因 """ - # 从缓存获取上次搜索结果 - results = await SearchChain().async_last_search_results() or [] + recommend_chain = SearchChain() + # 禁用与只读轮询仅访问推荐状态,不反序列化可能很大的搜索结果缓存。 + if not recommend_chain.is_ai_recommend_enabled: + return _SchemaResponse(success=True, data={"status": "disabled"}) + if check_only and not force: + current_status = recommend_chain.get_current_recommend_status_only() + if current_status.get("status") == "error": + error_msg = current_status.pop("error", "未知错误") + return _SchemaResponse(success=False, message=error_msg, data=current_status) + return _SchemaResponse(success=True, data=current_status) + + # 只有真正启动或检查推荐输入变化时才读取候选资源。 + results = await recommend_chain.async_last_search_results() or [] if not results: return _SchemaResponse( success=False, message="没有可用的搜索结果", data={"status": "error"} ) - recommend_chain = SearchChain() - # 如果是强制模式,先取消并清除旧结果,然后直接启动新任务 if force: - # 检查功能是否启用 - if not recommend_chain.is_ai_recommend_enabled: - return _SchemaResponse(success=True, data={"status": "disabled"}) logger.info("收到新推荐请求,清除旧结果并启动新任务") recommend_chain.cancel_ai_recommend() recommend_chain.start_recommend_task(filtered_indices, len(results), results) # 直接返回运行中状态 return _SchemaResponse(success=True, data={"status": "running"}) - # 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测) - if check_only: - # 返回当前运行状态,不做任何任务启动或取消操作 - current_status = recommend_chain.get_current_recommend_status_only() - # 如果有错误,将错误信息放到message中 - if current_status.get("status") == "error": - error_msg = current_status.pop("error", "未知错误") - return _SchemaResponse( - success=False, message=error_msg, data=current_status - ) - return _SchemaResponse(success=True, data=current_status) - # 获取当前状态(会检测请求是否变化) status_data = recommend_chain.get_recommend_status(filtered_indices, len(results)) diff --git a/app/schemas/media.py b/app/schemas/media.py index 66c5e0816..b0180d721 100644 --- a/app/schemas/media.py +++ b/app/schemas/media.py @@ -9,11 +9,11 @@ **表示规则**而非领域策略——真正的策略(按配置选来源、判音乐实体类型)仍留在 app/domain/media.py。放在这里,持久化层就不必为了一条表示规则去依赖领域层。 """ -from typing import TYPE_CHECKING, Any, Optional, Self, Tuple, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Self, Tuple, Union -from pydantic import model_validator +from pydantic import BaseModel, Field, model_validator -from app.schemas.types import MediaSource +from app.schemas.types import MediaSource, MediaType MEDIA_SOURCE_ALIASES = { "tmdb": MediaSource.TMDB, @@ -230,6 +230,32 @@ class OptionalMediaIdentityMixin: return self +class MediaStatusQuery(OptionalMediaIdentityMixin, BaseModel): + """ + 卡片状态查询身份,只使用本地媒体库与当前用户订阅,不调用外部媒体服务。 + """ + + media_source: Optional[MediaSource] = Field(default=None, description="主媒体来源,与 media_id 同时提供") + media_id: Optional[str] = Field(default=None, description="来源原生媒体标识") + title: Optional[str] = Field(default=None, description="用于历史身份回退的媒体标题") + year: Optional[str] = Field(default=None, description="用于精确匹配的发行年份") + mtype: Optional[MediaType] = Field(default=None, description="电影、电视剧或音乐") + season: Optional[int] = Field(default=None, description="当前展示季,省略时判断任意季") + music_type: Optional[str] = Field(default=None, description="音乐实体类型,避免混淆专辑与单曲") + + +class MediaStatusResult(BaseModel): + """ + 与输入顺序对应的本地入库、订阅状态和当前媒体各季订阅模式。 + """ + + item_id: Optional[str] = Field(default=None, description="本地媒体库条目标识,未入库时为空") + subscribed: bool = Field(default=False, description="当前用户是否订阅了查询媒体及指定季") + seasons: dict[int, Literal["normal", "best_version", "best_version_full"]] = Field( + default_factory=dict, description="同来源当前剧集的已订阅季及普通、洗版、完整洗版模式" + ) + + class RequiredMediaIdentityMixin: """ 为必填媒体身份模型统一校验内置或插件来源与原生 ID。 diff --git a/docker/nginx.template.conf b/docker/nginx.template.conf index 4b04d6500..cc052a0a8 100644 --- a/docker/nginx.template.conf +++ b/docker/nginx.template.conf @@ -46,6 +46,8 @@ http { upstream backend_api { # 后端仅通过容器内的回环地址代理访问 server 127.0.0.1:${PORT}; + # 与 API 代理的 HTTP/1.1 空 Connection 头配合,复用本地上游连接。 + keepalive 32; } } diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 4832d81e6..9dad795a3 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -817,7 +817,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1142 | -| 内部导入边 | 9,839 | +| 内部导入边 | 9,845 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index f5866f62a..846f59537 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -28,6 +28,19 @@ ### 大集合查询窗口 +`POST /api/v1/media/status` 接受 1 至 50 个媒体身份/标题/年份/季号查询,按输入顺序返回 +`item_id`、`subscribed` 与同来源剧集的各季订阅模式 `seasons`。查询仅访问本地媒体库和 +当前用户可见订阅,不调用外部媒体服务器,不返回完整订阅表。普通用户仍受 owner 限制, +影视保留标题、年份回退,音乐按实体类型隔离。该界面专用批量端点不新增 Agent 操作。 + +`search.recommend` 的 `check_only=true` 只读当前状态,不读取全量搜索结果缓存; +推荐禁用时也直接返回 `disabled`。`force=true` 保留优先级,确实启动任务时才读取候选。 +前端轮询等待上次请求结束后再间隔五秒,后台标签暂停发包,离开资源页后停止轮询。 + +卡片搜索配置与 `system/env` 在前端共享最多三十秒的只读快照,并合并并发请求; +各表单持有独立副本。相关站点、系统设置和订阅写操作成功后,以及登录身份变化后, +快照立即失效,在途旧结果不能写回新会话;失败查询允许重试。 + `GET /api/v1/site/userdata/{site_id}` 支持 `page` / `count`;趋势界面使用最近 90 条记录, 并传入 `latest_seeding_only=true`,仅窗口内最新记录携带 `seeding_info`,历史统计字段不变。 省略该选项时保留每条记录的完整明细;这不会删除或修改采集历史。 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index ded9b9450..639c791c1 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -MOVIEPILOT_API_SKILL_VERSION = "67" +MOVIEPILOT_API_SKILL_VERSION = "68" DATABASE_OPERATION_SKILL_VERSION = "16" diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 4b0cfc4c0..7d6bd11d9 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 67 +version: 68 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -303,6 +303,12 @@ Call the gateway with this shape: ### Common read and download contracts +`search.recommend` with `check_only=true` reads current status without loading search results. +Disabled recommendations return `disabled` immediately. `force=true` takes precedence over +the read-only flag and loads candidates to start a new task. +The browser endpoints `/media/status` and `/subscribe/calendar` are outside the Agent +operation catalog; do not invent gateway calls for them. + Select the operation for the task first, then send only fields declared by that operation. Common verification contracts are: | operation_id | `path_params` | `query` | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 748e0ed38..c8f8038bf 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -959,8 +959,8 @@ "runtime_only": true } }, - "edge_count": 9839, - "edge_sha256": "72918db401db3ef24d1360aa2f29169d114fa13bebf8e255e807cf38bc5a5082", + "edge_count": 9845, + "edge_sha256": "99374b39d7fc2d92d7bb86130dcaf3acced6fc093286c272cb7b3b2139e00d55", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2665,14 +2665,20 @@ "app.api.endpoints.media -> app.api.context", "app.api.endpoints.media -> app.api.dependencies", "app.api.endpoints.media -> app.api.dependencies.auth", + "app.api.endpoints.media -> app.api.dependencies.history", + "app.api.endpoints.media -> app.api.dependencies.subscription", + "app.api.endpoints.media -> app.api.principal", "app.api.endpoints.media -> app.api.response", "app.api.endpoints.media -> app.application", "app.api.endpoints.media -> app.application.classification", "app.api.endpoints.media -> app.application.classification.runtime", "app.api.endpoints.media -> app.application.configuration", + "app.api.endpoints.media -> app.application.mediaserver", "app.api.endpoints.media -> app.application.module", "app.api.endpoints.media -> app.application.plugin", "app.api.endpoints.media -> app.application.plugin.runtime", + "app.api.endpoints.media -> app.application.subscription", + "app.api.endpoints.media -> app.application.subscription.query", "app.api.endpoints.media -> app.chain", "app.api.endpoints.media -> app.chain.media", "app.api.endpoints.media -> app.chain.scraping", diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 1f49d6dcd..3738f415f 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "database-operation": "16", "feedback-issue": "12", "anysearch": "3", - "moviepilot-api": "67", + "moviepilot-api": "68", "moviepilot-update": "7", "organize-files": "6", "transfer-failed-retry": "6", diff --git a/tests/test_media_status.py b/tests/test_media_status.py new file mode 100644 index 000000000..1863531ee --- /dev/null +++ b/tests/test_media_status.py @@ -0,0 +1,98 @@ +""" +批量卡片状态的顺序、权限、轻量投影及查询窗口边界。 +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.dependencies.auth import get_current_active_user_async +from app.api.dependencies.history import get_mediaserver_query_service +from app.api.dependencies.subscription import get_subscription_query_service +from app.api.endpoints import media +from app.schemas.media import MediaStatusQuery +from app.schemas.subscribe import Subscribe + + +@pytest.fixture +def services(): + """ + 构造相互隔离的本地媒体库与订阅查询替身,避免访问数据库和外部服务。 + """ + return ( + SimpleNamespace(find_item_id=AsyncMock(return_value="local-item")), + SimpleNamespace( + list_by_media_identity=AsyncMock(return_value=[]), + list_by_video_metadata=AsyncMock(return_value=[]), + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("admin", [False, True]) +async def test_batch_status_keeps_owner_modes_identity_and_input_order(services, admin): + """ + 同媒体不同季只返回可见订阅模式,完整洗版不退化为普通洗版,且结果顺序对应输入。 + """ + library, subscriptions = services + subscriptions.list_by_media_identity.return_value = [ + Subscribe(id=1, username="alice", type="电视剧", season=1, best_version=0), + Subscribe(id=2, username="alice", type="电视剧", season=2, best_version=1, best_version_full=1), + Subscribe(id=3, username="bob", type="电视剧", season=3, best_version=1), + Subscribe(id=4, username=None, type="电视剧", season=4), + ] + items = [ + MediaStatusQuery(media_source="themoviedb", media_id="874", title="批量剧集", mtype="电视剧", season=season) + for season in [3, 1] + ] + result = await media.media_status(items, library, subscriptions, SimpleNamespace(name="alice", is_superuser=admin)) + assert [item.subscribed for item in result] == [admin, True] + expected = {1: "normal", 2: "best_version_full"} + if admin: + expected.update({3: "best_version", 4: "normal"}) + assert result[0].seasons == expected + assert result[0].item_id == "local-item" + assert set(result[0].model_dump()) == {"item_id", "subscribed", "seasons"} + assert [call.kwargs["season"] for call in library.find_item_id.await_args_list] == [3, 1] + assert subscriptions.list_by_media_identity.await_args.args[1] == "874" + + +@pytest.mark.asyncio +async def test_batch_status_preserves_video_metadata_fallback_and_music_entity(services): + """ + 影视保留跨来源标题年份回退,音乐只按实体身份查询且不调用影视媒体库。 + """ + library, subscriptions = services + subscriptions.list_by_video_metadata.return_value = [Subscribe(id=7, username="alice", type="电影")] + actor = SimpleNamespace(name="alice", is_superuser=False) + movie, music = await media.media_status([ + MediaStatusQuery(media_source="douban", media_id="movie-874", title="电影", year="2026", mtype="电影"), + MediaStatusQuery(media_source="musicbrainz", media_id="album-874", mtype="音乐", music_type="album"), + ], library, subscriptions, actor) + assert movie.subscribed is True + assert music.subscribed is False and music.item_id is None + library.find_item_id.assert_awaited_once() + subscriptions.list_by_video_metadata.assert_awaited_once() + assert subscriptions.list_by_video_metadata.await_args.kwargs["year"] == "2026" + assert subscriptions.list_by_media_identity.await_args.args[1:] == ("album-874", "album") + + +def test_batch_status_http_bounds_and_identity_validation(services): + """ + HTTP 层限制一次最多五十张卡片,拒绝半对媒体身份并返回标准数据 envelope。 + """ + library, subscriptions = services + app = FastAPI() + app.include_router(media.router, prefix="/media") + app.dependency_overrides[get_mediaserver_query_service] = lambda: library + app.dependency_overrides[get_subscription_query_service] = lambda: subscriptions + app.dependency_overrides[get_current_active_user_async] = lambda: SimpleNamespace(name="alice", is_superuser=False) + with TestClient(app) as client: + for payload in [[], [{}] * 51, [{"media_source": "themoviedb"}], [{"media_source": "themoviedb", "media_id": "0"}]]: + assert client.post("/media/status", json=payload).status_code == 422 + response = client.post("/media/status", json=[{"title": "电影", "mtype": "电影"}]) + assert response.status_code == 200 + assert response.json()["data"] == [{"item_id": "local-item", "subscribed": False, "seasons": {}}] diff --git a/tests/test_search_recommend_polling.py b/tests/test_search_recommend_polling.py new file mode 100644 index 000000000..f4db8591a --- /dev/null +++ b/tests/test_search_recommend_polling.py @@ -0,0 +1,69 @@ +""" +推荐状态轮询不得重复读取与反序列化全量搜索结果。 +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from app.api.endpoints.search import recommend_search_results + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["idle", "running", "completed", "error"]) +async def test_check_only_reads_status_without_search_cache_or_task_mutation(status): + """ + 所有只读状态均直接响应,包含空搜索缓存时的运行中状态和对外错误说明。 + """ + current = {"status": status} + if status == "error": + current["error"] = "推荐失败" + chain = SimpleNamespace( + is_ai_recommend_enabled=True, + async_last_search_results=AsyncMock(side_effect=AssertionError("不应读取资源缓存")), + get_current_recommend_status_only=Mock(return_value=current), + cancel_ai_recommend=Mock(), + start_recommend_task=Mock(), + ) + with patch("app.api.endpoints.search.SearchChain", return_value=chain): + result = await recommend_search_results(filtered_indices=None, check_only=True, force=False) + assert result.success is (status != "error") + assert result.data["status"] == status + if status == "error": + assert result.message == "推荐失败" + chain.async_last_search_results.assert_not_awaited() + chain.cancel_ai_recommend.assert_not_called() + chain.start_recommend_task.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("force", [False, True]) +async def test_disabled_recommend_skips_cache_even_for_force(force): + """ + 未开启推荐时立即返回禁用状态,强制参数也不能读取资源或创建任务。 + """ + chain = SimpleNamespace(is_ai_recommend_enabled=False, async_last_search_results=AsyncMock()) + with patch("app.api.endpoints.search.SearchChain", return_value=chain): + result = await recommend_search_results(filtered_indices=None, check_only=False, force=force) + assert result.data == {"status": "disabled"} + chain.async_last_search_results.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_force_keeps_precedence_over_check_only_and_receives_original_results(): + """ + 同时传入强制与只读参数时保留既有强制语义,并把完整候选仅读取一次后交给任务。 + """ + results = [object(), object()] + chain = SimpleNamespace( + is_ai_recommend_enabled=True, + async_last_search_results=AsyncMock(return_value=results), + cancel_ai_recommend=Mock(), start_recommend_task=Mock(), + ) + with patch("app.api.endpoints.search.SearchChain", return_value=chain): + result = await recommend_search_results(filtered_indices=[1], check_only=True, force=True) + assert result.data == {"status": "running"} + chain.async_last_search_results.assert_awaited_once() + chain.cancel_ai_recommend.assert_called_once() + chain.start_recommend_task.assert_called_once_with([1], 2, results)