From d512f528be351dabba0cf344c2e9d890bb93ea6f Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 1 Sep 2026 09:13:56 +0800 Subject: [PATCH] refactor(api): make collection pagination explicit --- app/agent/callback/__init__.py | 2 +- app/agent/middleware/skills.py | 135 +++++------ app/agent/policy/api_mcp_schema.json | 54 +++++ app/agent/policy/mcp.py | 6 +- app/agent/prompt/System Core Prompt.txt | 2 +- app/agent/skills/metadata.py | 7 +- app/agent/skills/registry.py | 11 +- app/api/endpoints/agent.py | 8 +- app/api/endpoints/auth.py | 9 +- app/api/endpoints/dashboard.py | 18 +- app/api/endpoints/discover.py | 8 +- app/api/endpoints/douban.py | 10 +- app/api/endpoints/download.py | 12 +- app/api/endpoints/login.py | 9 +- app/api/endpoints/mcp.py | 10 +- app/api/endpoints/media.py | 16 +- app/api/endpoints/mediaserver.py | 10 +- app/api/endpoints/mfa.py | 21 +- app/api/endpoints/plugin.py | 47 ++-- app/api/endpoints/recommend.py | 8 +- app/api/endpoints/search.py | 12 +- app/api/endpoints/site.py | 179 ++++++++++++--- app/api/endpoints/storage.py | 8 + app/api/endpoints/subscribe.py | 55 ++++- app/api/endpoints/system.py | 14 +- app/api/endpoints/tmdb.py | 12 +- app/api/endpoints/transfer.py | 8 +- app/api/endpoints/user.py | 26 ++- app/api/endpoints/workflow.py | 63 +++-- app/api/response.py | 98 ++++---- app/application/security/passkey.py | 29 ++- app/application/security/user.py | 27 ++- app/application/site/contract.py | 55 ++++- app/application/site/query.py | 120 ++++++++-- app/application/subscription/contract.py | 22 +- app/application/subscription/query.py | 29 ++- app/application/workflow.py | 61 ++++- app/db/adapters/site.py | 178 +++++++++++++-- app/db/adapters/subscription.py | 84 ++++++- app/db/adapters/user.py | 23 +- app/db/adapters/workflow.py | 37 ++- app/db/oper/passkey.py | 30 ++- app/db/oper/site.py | 215 ++++++++++++++++-- app/db/oper/subscribe.py | 81 +++++-- app/db/oper/workflow.py | 79 ++++++- app/locales/en-US.json | 5 + app/locales/zh-CN.json | 1 + app/locales/zh-TW.json | 5 + docs/architecture-overview.md | 2 +- docs/architecture/agent-tool-refactor-plan.md | 27 ++- docs/architecture/optimization-checklist.md | 6 +- docs/cli.md | 5 +- docs/mcp-api.md | 5 +- skills/moviepilot-api/SKILL.md | 4 +- .../architecture/dependency-baseline.json | 8 +- .../fixtures/architecture/mypy-baseline.json | 4 +- .../fixtures/architecture/ruff-baseline.json | 9 - tests/test_agent_api_projection_endpoints.py | 53 ++++- tests/test_agent_background_output.py | 2 +- tests/test_agent_skills_middleware.py | 54 +++-- tests/test_agent_summarization_streaming.py | 4 +- tests/test_agent_tool_streaming.py | 8 +- tests/test_api_response.py | 46 +++- tests/test_architecture_dependencies.py | 5 +- tests/test_builtin_skill_boundaries.py | 9 + tests/test_db_config_user_queries.py | 14 ++ tests/test_db_site_queries.py | 51 +++++ tests/test_db_subscribe_queries.py | 29 +++ tests/test_db_workflow_queries.py | 35 +++ tests/test_mcp_plugin_tools.py | 23 ++ tests/test_plugin_endpoint.py | 31 +++ tests/test_site_media_filter.py | 21 +- tests/test_user_repository.py | 19 ++ 73 files changed, 1982 insertions(+), 451 deletions(-) diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py index 4812c8a3c..ed07996ad 100644 --- a/app/agent/callback/__init__.py +++ b/app/agent/callback/__init__.py @@ -362,7 +362,7 @@ class StreamingHandler: tool_message = (tool_message or "").strip() tool_message_lower = tool_message.lower() - if tool_name == "skill": + if tool_name in {"read_skill", "skill"}: return "skill", tool_kwargs.get("name") if tool_name == "query_activity_log": return "activity_log", tool_kwargs.get("keyword") or tool_kwargs.get("date") diff --git a/app/agent/middleware/skills.py b/app/agent/middleware/skills.py index 6c0dac904..e37c5aa66 100644 --- a/app/agent/middleware/skills.py +++ b/app/agent/middleware/skills.py @@ -25,18 +25,14 @@ from pydantic import BaseModel, Field from app.agent.middleware.utils import append_to_system_message from app.agent.policy.sanitizer import sanitize_for_host, summarize_error -from app.agent.skills.metadata import ( - MAX_SKILL_FILE_SIZE, - SkillMetadata, - parse_skill_metadata, -) +from app.agent.skills.metadata import MAX_SKILL_CONTENT_BYTES, SkillMetadata, parse_skill_metadata from app.agent.tools.tags import ToolTag from app.runtime.log import logger -# 模型返回上限独立于领域层的磁盘读取上限;需要容纳完整的内置 API -# 合同,同时继续阻止接近 1 MiB 磁盘上限的异常 Skill 撑满上下文。 -MAX_SKILL_RESULT_CHARS = 256 * 1024 -SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)" +SKILL_CONTENT_TRUNCATION_MESSAGE = ( + "SKILL.md exceeds 512 KiB; content contains only the first 512 KiB. " + "Do not use read_file to bypass this limit." +) class SkillsState(AgentState): @@ -95,15 +91,9 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]: for skill_path in skill_dirs: skill_md_path = skill_path / "SKILL.md" - stat = await skill_md_path.stat() - if stat.st_size > MAX_SKILL_FILE_SIZE: - logger.warning( - "Skipping %s: file too large (%d bytes)", - skill_md_path, - stat.st_size, - ) - continue - skill_content = (await skill_md_path.read_bytes()).decode("utf-8", errors="replace") + async with await skill_md_path.open("rb") as handle: + raw_content = await handle.read(MAX_SKILL_CONTENT_BYTES) + skill_content = raw_content.decode("utf-8", errors="replace") # 解析元数据 skill_metadata = parse_skill_metadata( @@ -131,14 +121,9 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]: skills: list[SkillMetadata] = [] for skill_path in skill_dirs: skill_md_path = skill_path / "SKILL.md" - if skill_md_path.stat().st_size > MAX_SKILL_FILE_SIZE: - logger.warning( - "Skipping %s: file too large (%d bytes)", - skill_md_path, - skill_md_path.stat().st_size, - ) - continue - skill_content = skill_md_path.read_bytes().decode("utf-8", errors="replace") + with skill_md_path.open("rb") as handle: + raw_content = handle.read(MAX_SKILL_CONTENT_BYTES) + skill_content = raw_content.decode("utf-8", errors="replace") skill_metadata = parse_skill_metadata( content=skill_content, skill_path=str(skill_md_path), @@ -157,25 +142,26 @@ You have access to a skills library for specialized MoviePilot workflows. {skills_list} -When the user's request matches a skill description, call the `skill` tool with that skill name before taking task actions. Follow the loaded SKILL.md instructions, and load referenced supporting files only when needed. Do not create or rewrite skills unless the user explicitly asks for skill authoring. +When the user's request matches a skill description, call the `read_skill` tool with that skill name before taking task actions. Always use `read_skill`, never `read_file`, to load SKILL.md. The tool returns up to 512 KiB of SKILL.md plus the relative paths of all supporting files; if the body is truncated, do not use `read_file` to bypass the limit. Load only the listed supporting files that are actually needed. Do not create or rewrite skills unless the user explicitly asks for skill authoring. """ -SKILL_TOOL_NAME = "skill" +SKILL_TOOL_NAME = "read_skill" MOVIEPILOT_API_SKILL_NAME = "moviepilot-api" -SKILL_TOOL_DESCRIPTION = """Loads the full instructions for a MoviePilot skill by name or id. +SKILL_TOOL_DESCRIPTION = """Reads a MoviePilot skill by name or id. Available skills: {skills_catalog} -Call this tool when the user's task matches one of the available skills. The tool returns the SKILL.md content and metadata so you can follow the skill's instructions. Do not use this for simple tasks that do not need a skill. +Call this tool when the user's task matches one of the available skills. It returns up to 512 KiB of SKILL.md content, metadata, and every supporting file path relative to the skill directory. If the content is truncated, do not use read_file to bypass the limit. Always use this tool instead of read_file for SKILL.md. Use read_file only for a listed supporting file when its content is needed. Do not use this for simple tasks that do not need a skill. """ def _extract_version(skill_md: Path) -> int: """从 SKILL.md 文件中快速提取 version 字段,无法提取时返回 0。""" try: - content = skill_md.read_text(encoding="utf-8", errors="replace") + with skill_md.open("rb") as handle: + content = handle.read(MAX_SKILL_CONTENT_BYTES).decode("utf-8", errors="replace") except Exception as err: logger.debug(f"读取技能版本失败: {summarize_error(err)}") return 0 @@ -335,53 +321,32 @@ class _SkillToolProvider: @staticmethod async def _read_skill_content(skill_path: str) -> tuple[str, bool]: - """读取技能文件内容,并在超出上限时返回截断标记。""" + """读取最多 512 KiB 技能主体,并报告内容是否截断。""" path = AsyncPath(skill_path) - stat = await path.stat() - truncated = stat.st_size > MAX_SKILL_FILE_SIZE async with await path.open("rb") as handle: - raw_content = await handle.read(MAX_SKILL_FILE_SIZE) - return raw_content.decode("utf-8", errors="replace"), truncated + raw_content = await handle.read(MAX_SKILL_CONTENT_BYTES + 1) + truncated = len(raw_content) > MAX_SKILL_CONTENT_BYTES + bounded_content = raw_content[:MAX_SKILL_CONTENT_BYTES] + decode_errors = "ignore" if truncated else "replace" + return bounded_content.decode("utf-8", errors=decode_errors), truncated @staticmethod - def _serialize_skill_payload(payload: dict[str, Any]) -> str: - """序列化 Skill 返回值,并严格限制最终进入模型的字符数。""" - serialized = json.dumps(payload, ensure_ascii=False, indent=2) - if len(serialized) <= MAX_SKILL_RESULT_CHARS: - return serialized - - original_content = str(payload.get("content") or "") - truncated_payload = dict(payload) - truncated_payload["truncated"] = True - low = 0 - high = len(original_content) - best_result = json.dumps( - { - **truncated_payload, - "content": SKILL_CONTENT_TRUNCATION_SUFFIX.strip(), - }, - ensure_ascii=False, - indent=2, - ) - while low <= high: - middle = (low + high) // 2 - candidate = json.dumps( - { - **truncated_payload, - "content": (original_content[:middle] + SKILL_CONTENT_TRUNCATION_SUFFIX), - }, - ensure_ascii=False, - indent=2, - ) - if len(candidate) <= MAX_SKILL_RESULT_CHARS: - best_result = candidate - low = middle + 1 - else: - high = middle - 1 - return best_result + async def _list_supporting_files(skill_path: str) -> list[str]: + """列出技能目录内除 SKILL.md 外的全部普通文件相对路径。""" + skill_file = AsyncPath(skill_path) + skill_root = skill_file.parent + root_path = Path(str(skill_root)) + supporting_files = [] + async for path in skill_root.rglob("*", recurse_symlinks=False): + if await path.is_symlink() or not await path.is_file(): + continue + relative_path = Path(str(path)).relative_to(root_path).as_posix() + if relative_path != "SKILL.md": + supporting_files.append(relative_path) + return sorted(supporting_files, key=str.casefold) async def load_skill(self, name: str) -> str: - """加载指定 Skill 的完整说明并返回 JSON 字符串。""" + """加载指定 Skill 的受限主体和辅助文件列表并返回 JSON 字符串。""" logger.info(f"加载 Skill: name={sanitize_for_host(name)}") try: skill = await self._find_skill(name) @@ -395,11 +360,12 @@ class _SkillToolProvider: ) content, truncated = await self._read_skill_content(skill["path"]) + supporting_files = await self._list_supporting_files(skill["path"]) declared_operations = skill.get("allowed_api_operations", []) if declared_operations: self._api_scope_declared = True self._allowed_api_operations.update(declared_operations) - return self._serialize_skill_payload( + return json.dumps( { "success": True, "skill": { @@ -411,8 +377,13 @@ class _SkillToolProvider: "allowed_api_operations": declared_operations, }, "content": content, + "content_limit_bytes": MAX_SKILL_CONTENT_BYTES, + "supporting_files": supporting_files, "truncated": truncated, - } + "truncation_message": SKILL_CONTENT_TRUNCATION_MESSAGE if truncated else None, + }, + ensure_ascii=False, + indent=2, ) except Exception as err: error_summary = summarize_error(err) @@ -459,13 +430,15 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no 项目内置技能目录路径。若提供,在首次加载前会将其中不存在于 sources 首个目录的技能自动复制过去。 stream_handler : Optional[Any] - 流式输出处理器,用于记录 skill 工具调用摘要。 + 流式输出处理器,用于记录 read_skill 工具调用摘要。 """ self.sources = sources self.bundled_skills_dir = bundled_skills_dir self.stream_handler = stream_handler self.system_prompt_template = SKILLS_SYSTEM_PROMPT self._skill_provider = _SkillToolProvider(sources=sources) + # read_skill 保持为中间件私有 StructuredTool:不注册到 MoviePilotToolFactory, + # 因而不会进入 HTTP/MCP 工具目录,也不会经过 MoviePilotTool 的 64 KiB 结果裁剪。 self.tools = [ StructuredTool.from_function( coroutine=self._skill_provider.load_skill, @@ -499,7 +472,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no return list(all_skills.values()) def _refresh_skill_tool_description(self, skills: list[SkillMetadata]) -> None: - """刷新 skill 工具描述中的可用技能目录。""" + """刷新 read_skill 工具描述中的可用技能目录。""" if not self.tools: return self.tools[0].description = SKILL_TOOL_DESCRIPTION.format(skills_catalog=_format_skill_tool_catalog(skills)) @@ -577,7 +550,7 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no request: ToolCallRequest, handler: Callable[[ToolCallRequest], Awaitable[Any]], ) -> Any: - """在 skill 工具执行时记录聚合摘要。""" + """在 read_skill 工具执行时记录聚合摘要。""" tool = request.tool tool_name = getattr(tool, "name", None) tool_call = request.tool_call or {} @@ -627,4 +600,10 @@ class SkillsMiddleware(AgentMiddleware[SkillsState, ContextT, ResponseT]): # no return result -__all__ = ["MOVIEPILOT_API_SKILL_NAME", "SKILL_TOOL_NAME", "SkillMetadata", "SkillsMiddleware"] +__all__ = [ + "MAX_SKILL_CONTENT_BYTES", + "MOVIEPILOT_API_SKILL_NAME", + "SKILL_TOOL_NAME", + "SkillMetadata", + "SkillsMiddleware", +] diff --git a/app/agent/policy/api_mcp_schema.json b/app/agent/policy/api_mcp_schema.json index 626eb4aa2..5a951a633 100644 --- a/app/agent/policy/api_mcp_schema.json +++ b/app/agent/policy/api_mcp_schema.json @@ -8221,6 +8221,20 @@ "additionalProperties": false, "description": "Filters and control values for plugin.installed. List installed plugins and their runtime status. Use only the named fields below.", "properties": { + "count": { + "anyOf": [ + { + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.", + "title": "Count" + }, "force": { "default": false, "description": "Force a marketplace refresh or plugin installation when true.", @@ -8235,6 +8249,19 @@ "title": "Max Results", "type": "integer" }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.", + "title": "Page" + }, "query": { "anyOf": [ { @@ -8285,6 +8312,20 @@ "additionalProperties": false, "description": "Filters and control values for plugin.market. List plugins available from configured marketplaces. Use only the named fields below.", "properties": { + "count": { + "anyOf": [ + { + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.", + "title": "Count" + }, "force": { "default": false, "description": "Force a marketplace refresh or plugin installation when true.", @@ -8299,6 +8340,19 @@ "title": "Max Results", "type": "integer" }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.", + "title": "Page" + }, "query": { "anyOf": [ { diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index fcd0ec918..c6cd61058 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -960,13 +960,17 @@ def _collection_response_contract( query_parameters.get("page"), query_parameters.get("count"), ] + has_existing_window = bool( + {"limit", "offset", "page_size", "max_results"} + & query_parameters.keys() + ) defaults_to_unpaginated = all( isinstance(parameter, Mapping) and not parameter.get("required", False) and isinstance(parameter.get("schema"), Mapping) and "default" not in parameter["schema"] for parameter in compatibility_parameters - ) + ) and not has_existing_window return { "body_shape": "list", "result_count_field": "collection.result_count", diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 51d75e44c..ad590b17a 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -66,7 +66,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel - If `media.search` fails, fall back to `search_web` or `media.recognize`. Only ask the user when automated paths are exhausted. - If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable. - Reuse the latest torrent search cache through `search.results` before `download.add` instead of repeating the same search unnecessarily. -- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `moviepilot_api` operation `storage.list` for one known local or remote storage directory, with its paging fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended. +- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `moviepilot_api` operation `storage.list` for one known local or remote storage directory, with its paging fields when more than the first page is needed. Always use `read_skill`, never `read_file`, to load a skill's SKILL.md; `read_skill` returns up to 512 KiB of the skill body and the relative paths of its supporting files in one call. If the body is truncated, do not use `read_file` to bypass the limit. Use `read_file` when an exact non-SKILL.md local file is known, including a supporting file listed by `read_skill`. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended. - Read the relevant file before changing it, then pick the editing tool by scope. Use `apply_patch` when one logical change spans multiple files, adds new files, or deletes files: submit a single patch wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`, `*** Update File:`, and `*** Delete File:` sections; every context and removed line must match the current content exactly, and the whole patch is validated before any file is written. Use `edit_file` for a single localized exact replacement within one already-read file; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for one standalone new file; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters. - When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API. - Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial. diff --git a/app/agent/skills/metadata.py b/app/agent/skills/metadata.py index 9fe5518ec..b313950ca 100644 --- a/app/agent/skills/metadata.py +++ b/app/agent/skills/metadata.py @@ -6,8 +6,7 @@ import yaml logger = logging.getLogger(__name__) -# 磁盘读取上限属于 Skill 文档格式约束,市场扫描和 Agent 加载必须共用。 -MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024 +MAX_SKILL_CONTENT_BYTES = 512 * 1024 MAX_SKILL_NAME_LENGTH = 64 MAX_SKILL_DESCRIPTION_LENGTH = 1024 MAX_SKILL_COMPATIBILITY_LENGTH = 500 @@ -47,10 +46,6 @@ def parse_skill_metadata( # noqa: C901 skill_id: str, ) -> SkillMetadata | None: """解析并校验一个 SKILL.md 的 YAML 前言。""" - if len(content) > MAX_SKILL_FILE_SIZE: - logger.warning("Skipping %s: content too large (%d bytes)", skill_path, len(content)) - return None - match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) if not match: logger.warning("Skipping %s: no valid YAML frontmatter found", skill_path) diff --git a/app/agent/skills/registry.py b/app/agent/skills/registry.py index 3aa8f30b7..731f5620c 100644 --- a/app/agent/skills/registry.py +++ b/app/agent/skills/registry.py @@ -10,7 +10,7 @@ from typing import Dict, List, Optional, Tuple from urllib.parse import urlencode, urljoin, urlparse from app.adapters.network.http import RequestUtils -from app.agent.skills.metadata import parse_skill_metadata +from app.agent.skills.metadata import MAX_SKILL_CONTENT_BYTES, parse_skill_metadata from app.application.configuration import get_runtime_settings from app.foundation.singleton import WeakSingleton from app.foundation.url import UrlUtils @@ -387,7 +387,8 @@ class SkillHelper(metaclass=WeakSingleton): if not skill_md.exists(): continue try: - content = skill_md.read_text(encoding="utf-8", errors="replace") + with skill_md.open("rb") as handle: + content = handle.read(MAX_SKILL_CONTENT_BYTES).decode("utf-8", errors="replace") except Exception as e: logger.warning("读取技能文件失败:%s - %s", skill_md, e) continue @@ -552,7 +553,11 @@ class SkillHelper(metaclass=WeakSingleton): skill_dir = rel_path[: -len("/SKILL.md")] skill_id = Path(skill_dir).name try: - content = zf.read(archive_name).decode("utf-8") + with zf.open(archive_name, "r") as skill_file: + content = skill_file.read(MAX_SKILL_CONTENT_BYTES).decode( + "utf-8", + errors="replace", + ) except Exception as e: logger.warning("读取市场技能失败:%s - %s", archive_name, e) continue diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index 5b800593f..80f2058b6 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -23,7 +23,11 @@ from app.api.dependencies.agent import ( from app.api.dependencies.auth import get_current_active_user from app.api.presentation.sse import build_sse_response from app.api.principal import ApiPrincipal -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application import agent as agent_application from app.application.messaging import agent as web_agent_application from app.application.messaging.agent import ( @@ -295,6 +299,8 @@ async def web_agent_callback( ) async def list_web_agent_commands( current_user: ApiPrincipal = Depends(get_current_active_user), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> _SchemaResponse: """ 获取当前 Web 智能助手可补全的斜杠命令。 diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 5458f5a10..1531eeac8 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -5,7 +5,12 @@ from pydantic import BaseModel from app.adapters.web.security.access import set_or_refresh_resource_token_cookie from app.api.dependencies.auth import get_auth_service -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.api.response import ( + RAW_RESPONSE_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.plugin.runtime import get_plugin_manager from app.application.security.auth import AuthService, consume_plugin_auth_ticket from app.schemas.token import Token as _SchemaToken @@ -47,7 +52,7 @@ def _system_auth_providers(service: AuthService) -> list[dict[str, Any]]: summary="查询登录认证提供方", response_model=list[_SchemaAuthProviderInfo], ) -def auth_providers(service: AuthService = Depends(get_auth_service)) -> list[dict[str, Any]]: +def auth_providers(service: AuthService = Depends(get_auth_service), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> list[dict[str, Any]]: """ 查询系统和插件提供的登录认证入口。 diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index da8c46723..b83c97b75 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -8,7 +8,11 @@ from app.adapters.web.security.access import verify_apitoken from app.api.context import get_api_runtime_config, resolve_api_runtime_config from app.api.dependencies.auth import get_current_active_superuser from app.api.dependencies.history import get_dashboard_query_service -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.configuration import ApiRuntimeConfig from app.application.dashboard import DashboardQueryService from app.application.directory import DirectoryHelper @@ -125,7 +129,7 @@ def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.get("/processes", summary="进程信息", response_model=List[_SchemaProcessInfo]) -def processes(_: Any = Depends(get_current_active_superuser)) -> Any: +def processes(_: Any = Depends(get_current_active_superuser), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询进程信息 """ @@ -175,7 +179,7 @@ def downloader2( @router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo]) -async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any: +async def schedule(_: Any = Depends(get_current_active_superuser), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询后台服务信息 """ @@ -206,7 +210,7 @@ async def schedule_progress( summary="后台服务(API_TOKEN)", response_model=List[_SchemaScheduleInfo], ) -async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: +async def schedule2(_: Annotated[str, Depends(verify_apitoken)], page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询下载器信息 API_TOKEN认证(?token=xxx) """ @@ -237,6 +241,8 @@ async def transfer( days: Optional[int] = 7, service: DashboardQueryService = Depends(get_dashboard_query_service), _: Any = Depends(get_current_active_superuser), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询文件整理统计信息 @@ -285,7 +291,7 @@ def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: @router.get("/network", summary="获取当前网络流量", response_model=List[int]) -def network(_: Any = Depends(get_current_active_superuser)) -> Any: +def network(_: Any = Depends(get_current_active_superuser), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取当前网络流量(上行和下行流量,单位:bytes/s) """ @@ -295,7 +301,7 @@ def network(_: Any = Depends(get_current_active_superuser)) -> Any: @router.get( "/network2", summary="获取当前网络流量(API_TOKEN)", response_model=List[int] ) -def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: +def network2(_: Annotated[str, Depends(verify_apitoken)], page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取当前网络流量 API_TOKEN认证(?token=xxx) """ diff --git a/app/api/endpoints/discover.py b/app/api/endpoints/discover.py index f578afc8f..34f3c5277 100644 --- a/app/api/endpoints/discover.py +++ b/app/api/endpoints/discover.py @@ -3,7 +3,11 @@ from typing import Any, List, Optional from fastapi import Depends from app.adapters.web.security.access import verify_token -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain @@ -22,7 +26,7 @@ router = ResponseAPIRouter() summary="获取探索数据源", response_model=List[_SchemaDiscoverMediaSource], ) -def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +def source(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取探索数据源 """ diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 5f1352cf7..fa8e9cd0f 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -3,7 +3,11 @@ from typing import Any, List, Optional, Sequence from fastapi import Depends from app.adapters.web.security.access import verify_token -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.chain.douban import DoubanChain from app.domain.context import MediaInfo from app.schemas.context import MediaPerson as _SchemaMediaPerson @@ -59,7 +63,9 @@ async def douban_person_credits( response_model=List[_SchemaMediaPerson], ) async def douban_credits( - doubanid: str, type_name: str, _: _SchemaTokenPayload = Depends(verify_token) + doubanid: str, type_name: str, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 根据豆瓣ID查询演员阵容,type_name: 电影/电视剧 diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 900ab0445..137e627ea 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -7,7 +7,11 @@ from app.adapters.web.security.access import verify_token from app.api.dependencies.auth import get_current_active_user from app.api.dependencies.site import get_site_sync_query_service from app.api.principal import ApiPrincipal -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.configuration import get_configured_system_config from app.application.directory import DirectoryHelper from app.application.download.tasks import DownloadTaskMutationService @@ -204,7 +208,7 @@ def _resolve_add_media( @router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent]) -def current(name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: +def current(name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询正在下载的任务 """ @@ -405,7 +409,7 @@ async def update_task( summary="查询可用下载器", response_model=List[_SchemaServiceClientInfo], ) -async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def clients(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询可用下载器 """ @@ -416,7 +420,7 @@ async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: @router.get("/paths", summary="查询可用下载路径", response_model=List[_SchemaDownloadDirectory]) -def paths(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +def paths(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询可直接用于下载接口 save_path 参数的下载路径 """ diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 858528360..ce555555d 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -9,7 +9,12 @@ from fastapi.security import OAuth2PasswordRequestForm from app.adapters.web.security.access import set_or_refresh_resource_token_cookie from app.api.context import get_api_runtime_config, resolve_api_runtime_config from app.api.dependencies.auth import get_user_service -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.api.response import ( + RAW_RESPONSE_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.configuration import ApiRuntimeConfig, get_runtime_settings from app.application.image import WallpaperHelper from app.application.security.token import PasswordTooLongError, create_access_token, get_password_hash @@ -201,7 +206,7 @@ def wallpaper() -> Any: @router.get("/wallpapers", summary="登录页面电影海报列表", response_model=List[str]) -def wallpapers() -> Any: +def wallpapers(page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取登录页面电影海报 """ diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index e27d8d368..323fce2e1 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -5,7 +5,12 @@ from fastapi.responses import JSONResponse, Response from app.adapters.web.security.access import verify_apikey from app.agent.tools.manager import moviepilot_tool_manager -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.api.response import ( + RAW_RESPONSE_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.runtime.log import logger from app.runtime.version import get_app_version from app.schemas.mcp import MCP_JSONRPC_REQUEST_SCHEMA as _SchemaMCP_JSONRPC_REQUEST_SCHEMA @@ -30,6 +35,7 @@ MCP_HIDDEN_TOOLS = { "apply_patch", "write_file", "read_file", + "read_skill", } MCP_JSONRPC_ERROR_RESPONSES = { 400: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 请求错误"}, @@ -269,7 +275,7 @@ async def delete_mcp_session( summary="列出所有可用工具", response_model=List[_SchemaMcpToolInfo], ) -async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any: +async def list_tools(_: Annotated[str, Depends(verify_apikey)], page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取所有可用的工具列表 diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 4767a96d5..478aa1923 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -10,7 +10,11 @@ from app.api.dependencies.auth import ( get_current_active_superuser, get_current_active_user, ) -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.configuration import get_api_runtime_config_snapshot from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain @@ -383,7 +387,7 @@ async def search( summary="获取媒体数据源", response_model=list[_SchemaMediaSourceInfo], ) -def source(_: _SchemaTokenPayload = Depends(verify_token)) -> list[_SchemaMediaSourceInfo]: +def source(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> list[_SchemaMediaSourceInfo]: """返回内置及启用插件注册的媒体数据源,供前端统一构造来源选项。""" return _registered_media_sources() @@ -557,7 +561,9 @@ async def category(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: response_model=List[_SchemaMediaSeason], ) async def group_seasons( - episode_group: str, _: _SchemaTokenPayload = Depends(verify_token) + episode_group: str, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询剧集组季信息(themoviedb) @@ -576,7 +582,7 @@ async def group_seasons( summary="查询媒体剧集组", response_model=List[_SchemaMediaEpisodeGroup], ) -async def groups(tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def groups(tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询媒体剧集组列表(themoviedb) """ @@ -606,6 +612,8 @@ async def seasons( year: str = None, season: int = None, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询媒体季信息 diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index 5f3e6b487..dc20fce75 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -6,6 +6,8 @@ from app.adapters.web.security.access import verify_token from app.api.dependencies.history import get_mediaserver_query_service from app.api.response import ( COLLECTION_PAGINATION_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, ResponseAPIRouter, ) from app.application.configuration import get_configured_system_config @@ -155,7 +157,9 @@ def exists( openapi_extra={COLLECTION_PAGINATION_OPENAPI_KEY: True}, ) def not_exists( - media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token) + media_in: _SchemaMediaInfo, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 根据媒体信息查询缺失电影/剧集 @@ -234,6 +238,8 @@ def library( server: str, hidden: Optional[bool] = False, userinfo: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 获取媒体服务器媒体库列表 @@ -252,7 +258,7 @@ def library( summary="查询可用媒体服务器", response_model=List[_SchemaServiceClientInfo], ) -async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def clients(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询可用媒体服务器 """ diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 389bb6b82..a8cbc67eb 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -16,7 +16,15 @@ from app.api.dependencies.auth import ( get_user_service, ) from app.api.principal import ApiPrincipal -from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter +from app.api.response import ( + COLLECTION_TOTAL_HEADER, + COLLECTION_TOTAL_OPENAPI_KEY, + RAW_RESPONSE_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) from app.application.security.auth import get_configured_auth_service from app.application.security.otp import OtpUtils from app.application.security.passkey import ( @@ -449,14 +457,23 @@ def passkey_authenticate_finish( "/passkey/list", summary="获取当前用户的 PassKey 列表", response_model=_SchemaResponse[list[_SchemaPasskeyInfo]], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) def passkey_list( current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], + response: Response = None, service: PasskeyService = Depends(get_passkey_service), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """获取当前用户的所有 PassKey""" try: - passkeys = service.list_by_user_id(current_user.id) + page, count = resolve_compatible_pagination(page, count) + passkeys = service.list_by_user_id(current_user.id, page=page, count=count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + service.count_by_user_id(current_user.id) + ) key_list = ( [ diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 235ba64b3..4b75c445a 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -24,7 +24,14 @@ from app.api.dependencies.auth import ( ) from app.api.dependencies.plugin import get_plugin_config_command from app.api.principal import ApiPrincipal -from app.api.response import COLLECTION_TOTAL_HEADER, COLLECTION_TOTAL_OPENAPI_KEY, ResponseAPIRouter +from app.api.response import ( + COLLECTION_TOTAL_HEADER, + COLLECTION_TOTAL_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) from app.application.commands import init_commands from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config from app.application.plugin.catalog import get_plugin_catalog_query @@ -170,29 +177,31 @@ def _verify_plugin_static_file_access( verify_resource_token(resource_token) -@router.get( - "/", summary="所有插件", response_model=List[_SchemaPlugin], - openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, -) +@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin], openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}) async def all_plugins( _: ApiPrincipal = Depends(get_current_active_superuser_async), state: Optional[str] = "all", force: bool = False, query: Optional[str] = None, max_results: Annotated[int, Query(ge=1, le=200)] = 50, + page: CompatiblePageParam = None, count: CompatibleCountParam = None, response: Response = None, ) -> List[_SchemaPlugin]: - """查询插件清单,并支持 Agent 使用关键字和有界结果完成精确选择。""" + """查询插件清单,显式分页优先于兼容的 ``max_results`` 限量。""" plugins = await get_plugin_catalog_query().query(state=state or "all", force=force) if query: plugins = [item["plugin"] for item in search_plugin_candidates(query, plugins)] if response is not None: response.headers[COLLECTION_TOTAL_HEADER] = str(len(plugins)) + if page is not None or count is not None: + page, count = resolve_compatible_pagination(page, count) + assert page is not None and count is not None + return plugins[(page - 1) * count : page * count] return plugins[:max_results] @router.get("/installed", summary="已安装插件", response_model=List[str]) -async def installed(_: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any: +async def installed(_: ApiPrincipal = Depends(get_current_active_superuser_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询用户已安装插件清单 """ @@ -533,12 +542,8 @@ async def change_plugin_source( ) -@router.get( - "/remotes", - summary="获取插件联邦组件列表", - response_model=List[_SchemaPluginRemoteInfo], -) -async def remotes(token: str) -> Any: +@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[_SchemaPluginRemoteInfo]) +async def remotes(token: str, page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取插件联邦组件列表 """ @@ -547,12 +552,8 @@ async def remotes(token: str) -> Any: return get_plugin_manager().get_plugin_remotes() -@router.get( - "/sidebar_nav", - summary="获取插件侧栏导航项", - response_model=List[_SchemaPluginSidebarNavItem], -) -def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +@router.get("/sidebar_nav", summary="获取插件侧栏导航项", response_model=List[_SchemaPluginSidebarNavItem]) +def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 聚合已启用 Vue 插件声明的侧栏入口(get_sidebar_nav),供前端主界面侧栏展示。 """ @@ -619,13 +620,11 @@ def plugin_page(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_sup return {} -@router.get( - "/dashboard/meta", - summary="获取所有插件仪表板元信息", - response_model=List[_SchemaPluginDashboardMetaItem], -) +@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息", response_model=List[_SchemaPluginDashboardMetaItem]) def plugin_dashboard_meta( _: ApiPrincipal = Depends(get_current_active_superuser), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[dict]: """ 获取所有插件仪表板元信息 diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index d1dc07ae3..565207227 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -4,7 +4,11 @@ from typing import Any, List, Optional from fastapi import Depends, HTTPException, status from app.adapters.web.security.access import verify_token -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.music.projection import simplify_music_info from app.chain.listenbrainz import ( LISTENBRAINZ_CHART_RANGES, @@ -250,7 +254,7 @@ def _project_agent_recommendations(results: List[Any], count: int) -> List[dict[ summary="获取推荐数据源", response_model=List[_SchemaRecommendMediaSource], ) -def source(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +def source(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取推荐数据源 """ diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index b959f4f43..d383849cd 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -8,7 +8,11 @@ from fastapi import Body, Depends, Request from fastapi.responses import StreamingResponse from app.adapters.web.security.access import verify_resource_token, verify_token -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.security.url import SecurityUtils from app.chain.search.facade import SearchChain from app.domain.context import Context @@ -340,7 +344,7 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di @router.get("/last", summary="查询搜索结果", response_model=List[_SchemaContext]) -async def search_latest(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def search_latest(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询搜索结果 """ @@ -450,6 +454,8 @@ async def search_by_id( sites: Optional[str] = None, music_type: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 根据媒体来源和原生 ID 精确搜索站点资源。 @@ -724,6 +730,8 @@ async def search_subtitle_by_id( episode: Optional[str] = None, sites: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 根据媒体来源和原生 ID 精确搜索站点字幕资源。 diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index b7c4fcdf2..1cc43418c 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -1,6 +1,6 @@ from typing import Annotated, Any, Dict, List, Literal, Optional -from fastapi import Depends, HTTPException +from fastapi import Depends, HTTPException, Response from app.adapters.web.security.access import verify_token from app.api.context import get_background_task_registry, resolve_background_task_registry @@ -18,7 +18,14 @@ from app.api.dependencies.site import ( ) from app.api.endpoints.plugin import register_plugin_api from app.api.principal import ApiPrincipal -from app.api.response import ResponseAPIRouter +from app.api.response import ( + COLLECTION_TOTAL_HEADER, + COLLECTION_TOTAL_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) from app.application.commands import init_commands from app.application.configuration import get_configured_system_config from app.application.plugin.runtime import get_plugin_manager @@ -119,38 +126,75 @@ def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool: return True -@router.get("/", summary="所有站点", response_model=List[_SchemaSite]) +def _normalize_site_ids(values: Any) -> list[int]: + """把配置或索引器中的站点标识归一为可查询的整数主键。""" + normalized: list[int] = [] + for value in values or []: + try: + normalized.append(int(value)) + except (TypeError, ValueError): + continue + return list(dict.fromkeys(normalized)) + + +@router.get( + "/", + summary="所有站点", + response_model=List[_SchemaSite], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, +) async def read_sites( + response: Response = None, query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[dict]: """ 获取站点列表 """ - return await query.list_ordered() + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_ordered() + ) + return await query.list_ordered(page=page, count=count) @router.get( # type: ignore[misc] "/agent", summary="查询 Agent 可用站点", response_model=List[_SchemaJsonObject], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_agent_sites( + response: Response = None, status: Literal["active", "inactive", "all"] = "all", name: Optional[str] = None, query: SiteQueryService = Depends(get_site_query_service), current_user: Any = Depends(get_current_active_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[dict[str, JsonData]]: """按旧 Agent 过滤语义返回站点,非超级管理员自动剔除认证字段。""" - sites = await query.list_ordered() + active_filter = None + if status == "active": + active_filter = True + elif status == "inactive": + active_filter = False + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_ordered(is_active=active_filter, name=name) + ) + sites = await query.list_ordered( + is_active=active_filter, + name=name, + page=page, + count=count, + ) results = [] for site in sites: - if status == "active" and not site.is_active: - continue - if status == "inactive" and site.is_active: - continue - if name and name.lower() not in (site.name or "").lower(): - continue results.append( _project_agent_site( site, @@ -164,11 +208,15 @@ async def read_agent_sites( "/media/{media_type}", summary="按媒体类型获取可搜索站点", response_model=List[_SchemaSite], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_sites_by_media_type( media_type: str, + response: Response = None, query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[_SchemaSite]: """ 获取支持指定媒体类型的已配置启用站点。 @@ -186,24 +234,35 @@ async def read_sites_by_media_type( if target_media_type not in (MediaType.MOVIE, MediaType.TV, MediaType.MUSIC): raise HTTPException(status_code=400, detail="不支持的媒体类型") - supported_ids = set() - supported_domains = set() + supported_ids: set[int] = set() + supported_domains: set[str] = set() for indexer in await SitesHelper().async_get_indexers() or []: if not _indexer_supports_media_type(indexer, target_media_type): continue if indexer.get("id") is not None: - supported_ids.add(str(indexer.get("id"))) + supported_ids.update(_normalize_site_ids([indexer.get("id")])) domain = site_rules.extract_domain(indexer.get("domain")) if domain: supported_domains.add(domain) - sites = await query.list_ordered() - return [ - site - for site in sites - if site.is_active - and (str(site.id) in supported_ids or site.domain in supported_domains) - ] + site_ids = sorted(supported_ids) + domains = sorted(supported_domains) + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_ordered( + is_active=True, + site_ids=site_ids, + domains=domains, + ) + ) + return await query.list_ordered( + is_active=True, + site_ids=site_ids, + domains=domains, + page=page, + count=count, + ) @router.post("/", summary="新增站点", response_model=_SchemaResponse[None]) @@ -394,27 +453,40 @@ def refresh_userdata( "/userdata/latest", summary="查询所有站点最新用户数据", response_model=List[_SchemaSiteUserData], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_userdata_latest( + response: Response = None, query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询所有站点最新用户数据 """ - return await query.userdata_latest() + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_userdata_latest() + ) + return await query.userdata_latest(page=page, count=count) @router.get( "/userdata/{site_id}", summary="查询某站点用户数据", response_model=_SchemaResponse[list[_SchemaSiteUserData]], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_userdata( site_id: int, + response: Response = None, workdate: Optional[str] = None, query: SiteQueryService = Depends(get_site_query_service), _: ApiPrincipal = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询站点用户数据 @@ -425,7 +497,22 @@ async def read_userdata( status_code=404, detail=f"站点 {site_id} 不存在", ) - user_datas = await query.userdata(site.domain, workdate) + if not site.domain: + raise HTTPException( + status_code=409, + detail=f"站点 {site_id} 未配置域名", + ) + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_userdata(site.domain, workdate) + ) + user_datas = await query.userdata( + site.domain, + workdate, + page=page, + count=count, + ) if not user_datas: return _SchemaResponse(success=False, data=[]) return _SchemaResponse(success=True, data=user_datas) @@ -482,6 +569,8 @@ async def site_category( site_id: int, query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 获取站点分类 @@ -579,22 +668,41 @@ async def read_statistic_by_domain( @router.get( - "/statistic", summary="所有站点统计信息", response_model=List[_SchemaSiteStatistic] + "/statistic", + summary="所有站点统计信息", + response_model=List[_SchemaSiteStatistic], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def read_statistics( + response: Response = None, query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 获取所有站点统计信息 """ - return await query.statistics() + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_statistics() + ) + return await query.statistics(page=page, count=count) -@router.get("/rss", summary="所有订阅站点", response_model=List[_SchemaSite]) +@router.get( + "/rss", + summary="所有订阅站点", + response_model=List[_SchemaSite], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, +) async def read_rss_sites( + response: Response = None, query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[dict]: """ 获取站点列表 @@ -602,14 +710,17 @@ async def read_rss_sites( # 选中的rss站点 selected_sites = get_configured_system_config().get(SystemConfigKey.RssSites) or [] - # 所有站点 - all_site = await query.list_ordered() - if not selected_sites: - return all_site - - # 选中的rss站点 - rss_sites = [site for site in all_site if site and site.id in selected_sites] - return rss_sites + site_ids = _normalize_site_ids(selected_sites) if selected_sites else None + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_ordered(site_ids=site_ids) + ) + return await query.list_ordered( + site_ids=site_ids, + page=page, + count=count, + ) @router.get("/auth", summary="查询认证站点", response_model=_SchemaJsonObject) diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index 696d2f29e..7f0626c98 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -15,6 +15,8 @@ from app.api.dependencies.auth import ( from app.api.principal import ApiPrincipal from app.api.response import ( COLLECTION_PAGINATION_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, ResponseAPIRouter, ) from app.application.configuration import get_api_runtime_config_snapshot @@ -43,6 +45,8 @@ def directory_settings( storage_type: str = "all", name: Optional[str] = None, _: ApiPrincipal = Depends(get_current_active_superuser), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> _SchemaResponse[Any]: """按用途、存储类型和名称筛选目录配置。""" helper = DirectoryHelper() @@ -123,6 +127,8 @@ def list_files( sort: Optional[str] = "updated_at", keyword: Optional[str] = None, _: ApiPrincipal = Depends(get_current_active_manage_user), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询当前目录下所有目录和文件 @@ -165,6 +171,8 @@ def list_agent_files( sort: Optional[str] = "updated_at", keyword: Optional[str] = None, _: Any = Depends(get_current_active_user), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[_SchemaFileItem]: """保留旧 Agent 普通用户目录读取能力,不开放创建、改名或删除入口。""" return _list_files(fileitem=fileitem, sort=sort, keyword=keyword) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 162c21b94..64eed6559 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -29,7 +29,10 @@ from app.api.principal import ApiPrincipal from app.api.response import ( COLLECTION_TOTAL_HEADER, COLLECTION_TOTAL_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, ResponseAPIRouter, + resolve_compatible_pagination, ) from app.application.configuration import ( get_api_runtime_config_snapshot, @@ -150,30 +153,51 @@ def matches_subscribe_music_type( or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None) -@router.get("/", summary="查询所有订阅", response_model=List[_SchemaSubscribe]) +@router.get( + "/", + summary="查询所有订阅", + response_model=List[_SchemaSubscribe], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, +) async def read_subscribes( + response: Response = None, query: SubscriptionQueryService = Depends(get_subscription_query_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询所有订阅 """ - if not current_user.is_superuser: - return await query.list_public(current_user.name) - return await query.list_public() + username = None if current_user.is_superuser else current_user.name + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_public(username) + ) + return await query.list_public(username, page=page, count=count) @router.get( - "/list", summary="查询所有订阅(API_TOKEN)", response_model=List[_SchemaSubscribe] + "/list", + summary="查询所有订阅(API_TOKEN)", + response_model=List[_SchemaSubscribe], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_subscribes( + response: Response = None, query: SubscriptionQueryService = Depends(get_subscription_query_service), _: Annotated[str, Depends(verify_apitoken)] = None, + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询所有订阅 API_TOKEN认证(?token=xxx) """ - return await query.list_public() + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count_public()) + return await query.list_public(page=page, count=count) @router.post( @@ -659,19 +683,30 @@ async def popular_subscribes( @router.get( - "/user/{username}", summary="用户订阅", response_model=List[_SchemaSubscribe] + "/user/{username}", + summary="用户订阅", + response_model=List[_SchemaSubscribe], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def user_subscribes( username: str, + response: Response = None, query: SubscriptionQueryService = Depends(get_subscription_query_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询用户订阅 """ if not current_user.is_superuser and username != current_user.name: return [] - return await query.list_public(username) + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count_public(username) + ) + return await query.list_public(username, page=page, count=count) @router.get( @@ -755,7 +790,7 @@ async def subscribe_fork( @router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str]) -async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询已Follow的订阅分享人 """ @@ -830,6 +865,8 @@ async def subscribe_shares( ) async def subscribe_share_statistics( _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询订阅分享统计 diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 455878a1e..4490407f7 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -18,7 +18,7 @@ from app.api.dependencies.auth import ( get_current_active_user_async, ) from app.api.principal import ApiPrincipal -from app.api.response import ResponseAPIRouter +from app.api.response import CompatibleCountParam, CompatiblePageParam, ResponseAPIRouter from app.application.backup import DatabaseBackupInProgressError from app.application.configuration import ( get_configured_system_config, @@ -349,13 +349,11 @@ async def get_user_global_setting( return _SchemaResponse(success=True, data=info) -@router.get( - "/database/backups", - summary="查询受管数据库备份", - response_model=list[_SchemaDatabaseBackupArtifactData], -) +@router.get("/database/backups", summary="查询受管数据库备份", response_model=list[_SchemaDatabaseBackupArtifactData]) async def list_database_backups( _: ApiPrincipal = Depends(get_current_active_superuser_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> list[_SchemaDatabaseBackupArtifactData]: """列出当前备份目录中的正式制品,不触发内容校验。""" try: @@ -874,6 +872,8 @@ async def download_logging( async def latest_version( _: _SchemaTokenPayload = Depends(verify_token), runtime: HostRuntime = Depends(get_host_runtime), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ): """ 查询Github所有Release版本 @@ -966,7 +966,7 @@ def ruletest( summary="获取网络测试目标", response_model=_SchemaResponse[list[_SchemaNetTestTarget]], ) -async def nettest_targets(_: _SchemaTokenPayload = Depends(verify_token)): +async def nettest_targets(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None): """ 获取网络测试目标。 diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index a3184e714..b51a87069 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -4,7 +4,11 @@ from fastapi import Depends from app.adapters.web.security.access import verify_token from app.api.dependencies.auth import get_current_active_superuser_async -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config from app.chain.tmdb import TmdbChain from app.schemas.context import MediaPerson as _SchemaMediaPerson @@ -84,7 +88,9 @@ async def clear_tmdb_recognition_cache( "/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[_SchemaTmdbSeason] ) async def tmdb_seasons( - tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token) + tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 根据TMDBID查询themoviedb所有季信息 @@ -234,6 +240,8 @@ async def tmdb_season_episodes( season: int, episode_group: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 根据TMDBID查询某季的所有信信息 diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 8947e2832..1bea2f754 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -6,7 +6,11 @@ from fastapi import Depends, HTTPException, Query, status from app.adapters.web.security.access import verify_apitoken, verify_token from app.api.dependencies.auth import get_current_active_manage_user from app.api.dependencies.history import get_transfer_execution_repository, get_transfer_history_lookup_service -from app.api.response import ResponseAPIRouter +from app.api.response import ( + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, +) from app.application.configuration import get_api_runtime_config_snapshot from app.application.directory import DirectoryHelper from app.application.history import TransferHistoryLookupService @@ -234,7 +238,7 @@ def query_name( @router.get("/queue", summary="查询整理队列", response_model=List[_SchemaTransferJob]) -async def query_queue(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: +async def query_queue(_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 查询整理队列 :param _: Token校验 diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index 1e13d65b0..551109ea7 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -2,14 +2,21 @@ import base64 import re from typing import Annotated, Any, List, Union -from fastapi import Body, Depends, File, HTTPException, UploadFile +from fastapi import Body, Depends, File, HTTPException, Response, UploadFile from app.api.dependencies.auth import ( get_current_active_superuser_async, get_current_active_user_async, get_user_service, ) -from app.api.response import ResponseAPIRouter +from app.api.response import ( + COLLECTION_TOTAL_HEADER, + COLLECTION_TOTAL_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) from app.application.security.token import PasswordTooLongError, get_password_hash from app.application.security.user import ( LastActiveSuperuserError, @@ -46,15 +53,26 @@ def _prepare_password(user_info: dict[str, Any]) -> str | None: return None -@router.get("/", summary="所有用户", response_model=List[_SchemaUser]) +@router.get( + "/", + summary="所有用户", + response_model=List[_SchemaUser], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, +) async def list_users( + response: Response = None, service: UserService = Depends(get_user_service), current_user: Any = Depends(get_current_active_superuser_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 查询用户列表 """ - return await service.list() + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str(await service.count()) + return await service.list(page=page, count=count) @router.post("/", summary="新增用户", response_model=_SchemaResponse[None]) diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index cb275a9f2..45cdd3d40 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -1,6 +1,6 @@ from typing import Any, List, Literal, Optional -from fastapi import Depends +from fastapi import Depends, Response from app.adapters.external.server import MoviePilotServerHelper from app.api.dependencies.auth import ( @@ -12,7 +12,14 @@ from app.api.dependencies.workflow import ( get_workflow_mutation_command, get_workflow_query_service, ) -from app.api.response import ResponseAPIRouter +from app.api.response import ( + COLLECTION_TOTAL_HEADER, + COLLECTION_TOTAL_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) from app.application.plugin.runtime import get_plugin_manager from app.application.workflow import ( WorkflowDefinitionCommand, @@ -32,40 +39,66 @@ from app.schemas.workflow import WorkflowShare as _SchemaWorkflowShare router = ResponseAPIRouter() -@router.get("/", summary="所有工作流", response_model=List[_SchemaWorkflow]) +@router.get( + "/", + summary="所有工作流", + response_model=List[_SchemaWorkflow], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, +) async def list_workflows( + response: Response = None, query: WorkflowQueryService = Depends(get_workflow_query_service), _: Any = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 获取工作流列表 """ - return await query.list() + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str(await query.count()) + return await query.list(page=page, count=count) @router.get( # type: ignore[misc] "/agent", summary="查询 Agent 可用工作流", response_model=List[_SchemaJsonObject], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, ) async def list_agent_workflows( + response: Response = None, state: Literal["W", "R", "P", "S", "F", "all"] = "all", name: Optional[str] = None, trigger_type: Literal["timer", "event", "manual", "all"] = "all", query: WorkflowQueryService = Depends(get_workflow_query_service), _: Any = Depends(get_current_active_manage_user_async), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> List[dict[str, Any]]: """按旧 Agent 过滤与字段投影返回工作流列表,避免输出完整动作上下文。""" - workflows = await query.list() + state_filter = None if state == "all" else state + trigger_filter = None if trigger_type == "all" else trigger_type + page, count = resolve_compatible_pagination(page, count) + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str( + await query.count( + state=state_filter, + name=name, + trigger_type=trigger_filter, + ) + ) + workflows = await query.list( + state=state_filter, + name=name, + trigger_type=trigger_filter, + page=page, + count=count, + ) results = [] for workflow in workflows: - if state != "all" and workflow.state != state: - continue normalized_trigger = workflow.trigger_type or "timer" - if trigger_type != "all" and normalized_trigger != trigger_type: - continue - if name and name.lower() not in (workflow.name or "").lower(): - continue results.append( { "id": workflow.id, @@ -103,7 +136,9 @@ async def create_workflow( response_model=List[_SchemaPluginWorkflowActionGroup], ) def list_plugin_actions( - plugin_id: str = None, _: Any = Depends(get_current_active_manage_user) + plugin_id: str = None, _: Any = Depends(get_current_active_manage_user), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, ) -> Any: """ 获取所有动作 @@ -116,7 +151,7 @@ def list_plugin_actions( summary="所有动作", response_model=List[_SchemaWorkflowActionDefinition], ) -async def list_actions(_: Any = Depends(get_current_active_manage_user_async)) -> Any: +async def list_actions(_: Any = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取所有动作 """ @@ -128,7 +163,7 @@ async def list_actions(_: Any = Depends(get_current_active_manage_user_async)) - summary="获取所有事件类型", response_model=List[_SchemaNameValueOption], ) -async def get_event_types(_: Any = Depends(get_current_active_manage_user_async)) -> Any: +async def get_event_types(_: Any = Depends(get_current_active_manage_user_async), page: CompatiblePageParam = None, count: CompatibleCountParam = None) -> Any: """ 获取所有事件类型 """ diff --git a/app/api/response.py b/app/api/response.py index 97eaf15fd..aad8a61ea 100644 --- a/app/api/response.py +++ b/app/api/response.py @@ -1,9 +1,9 @@ import inspect import json from functools import wraps -from typing import Annotated, Any, Awaitable, Callable, Optional, get_args, get_origin +from typing import Annotated, Any, Awaitable, Callable, Optional, cast, get_args, get_origin -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Query, Request from fastapi.datastructures import DefaultPlaceholder from fastapi.responses import JSONResponse from fastapi.routing import APIRoute, get_typed_return_annotation @@ -33,6 +33,27 @@ COLLECTION_PAGE_HEADER = "X-Page" COLLECTION_PAGE_SIZE_HEADER = "X-Page-Size" COLLECTION_DEFAULT_PAGE_SIZE = 50 COLLECTION_MAX_PAGE_SIZE = 200 +CompatiblePageParam = Annotated[ + Optional[int], + Query( + ge=1, + description=( + "Optional one-based page for a legacy full-list endpoint. Omit both page and " + "count to keep the original unpaginated full result." + ), + ), +] +CompatibleCountParam = Annotated[ + Optional[int], + Query( + ge=1, + le=COLLECTION_MAX_PAGE_SIZE, + description=( + "Optional page size for a legacy full-list endpoint. Supplying page or count " + f"activates pagination; an omitted count then uses {COLLECTION_DEFAULT_PAGE_SIZE}." + ), + ), +] _COLLECTION_WINDOW_PARAMETERS = frozenset( {"page", "count", "limit", "offset", "page_size", "max_results"} ) @@ -52,31 +73,14 @@ _COLLECTION_RESPONSE_HEADERS = { } -def _optional_collection_pagination( - page: Annotated[ - Optional[int], - Query( - ge=1, - description=( - "Optional one-based page for a legacy full-list endpoint. Omit both page and " - "count to keep the original unpaginated full result." - ), - ), - ] = None, - count: Annotated[ - Optional[int], - Query( - ge=1, - le=COLLECTION_MAX_PAGE_SIZE, - description=( - "Optional page size for a legacy full-list endpoint. Supplying page or count " - f"activates pagination; an omitted count then uses {COLLECTION_DEFAULT_PAGE_SIZE}." - ), - ), - ] = None, -) -> None: - """校验兼容分页参数;实际切片由统一响应路由在序列化后执行。""" - del page, count +def resolve_compatible_pagination( + page: Optional[int], + count: Optional[int], +) -> tuple[Optional[int], Optional[int]]: + """解析兼容分页窗口;两项均省略时保留原全量查询语义。""" + if page is None and count is None: + return None, None + return page or 1, count or COLLECTION_DEFAULT_PAGE_SIZE class ResponseAPIRoute(APIRoute): @@ -120,15 +124,21 @@ class ResponseAPIRoute(APIRoute): endpoint_parameters = set(inspect.signature(endpoint).parameters) collection_response = self._is_collection_response_model(response_model) collection_window_parameters = endpoint_parameters & _COLLECTION_WINDOW_PARAMETERS + collection_parameter_defaults = self._parameter_defaults( + endpoint, + collection_window_parameters, + ) + explicit_compatible_pagination = ( + {"page", "count"}.issubset(endpoint_parameters) + and collection_parameter_defaults.get("page") is None + and collection_parameter_defaults.get("count") is None + ) optional_collection_pagination = bool( collection_response and ("GET" in methods or force_collection_pagination) - and not collection_window_parameters + and explicit_compatible_pagination + and not endpoint_reports_collection_total ) - if optional_collection_pagination: - dependencies = list(kwargs.get("dependencies") or []) - dependencies.append(Depends(_optional_collection_pagination)) - kwargs["dependencies"] = dependencies if collection_response: kwargs["responses"] = self._merge_collection_response_headers( kwargs.get("responses"), @@ -140,10 +150,7 @@ class ResponseAPIRoute(APIRoute): self._collection_response = collection_response self._optional_collection_pagination = optional_collection_pagination - self._collection_parameter_defaults = self._parameter_defaults( - endpoint, - collection_window_parameters, - ) + self._collection_parameter_defaults = collection_parameter_defaults should_wrap = self._should_wrap_response( response_model=response_model, @@ -165,7 +172,10 @@ class ResponseAPIRoute(APIRoute): self, ) -> Callable[[Request], Awaitable[StarletteResponse]]: """在标准端点序列化后附加兼容列表分页与数量元数据。""" - original_handler = super().get_route_handler() + original_handler = cast( + Callable[[Request], Awaitable[StarletteResponse]], + super().get_route_handler(), + ) if not self._collection_response: return original_handler @@ -306,13 +316,15 @@ class ResponseAPIRoute(APIRoute): if self._optional_collection_pagination and ( "page" in request.query_params or "count" in request.query_params ): - page = int(request.query_params.get("page", "1")) - page_size = int( - request.query_params.get( - "count", - str(COLLECTION_DEFAULT_PAGE_SIZE), - ) + page, page_size = resolve_compatible_pagination( + int(request.query_params["page"]) + if "page" in request.query_params + else None, + int(request.query_params["count"]) + if "count" in request.query_params + else None, ) + assert page is not None and page_size is not None start = (page - 1) * page_size paged_items = items[start : start + page_size] if isinstance(payload, dict): diff --git a/app/application/security/passkey.py b/app/application/security/passkey.py index e00d26c42..04bbd0303 100644 --- a/app/application/security/passkey.py +++ b/app/application/security/passkey.py @@ -460,8 +460,16 @@ class PasskeyRepository(Protocol): def list(self) -> list[Any]: """列出全部启用凭证。""" - def list_by_user_id(self, user_id: int) -> List[Any]: - """列出指定用户凭证。""" + def list_by_user_id( + self, + user_id: int, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[Any]: + """按可选窗口列出指定用户凭证。""" + + def count_by_user_id(self, user_id: int) -> int: + """返回指定用户启用凭证总数。""" def get_by_credential_id(self, credential_id: str) -> Optional[Any]: """按凭证 ID 查找凭证。""" @@ -492,9 +500,20 @@ class PasskeyService: """列出全部启用凭证。""" return self._repository.list() - def list_by_user_id(self, user_id: int) -> List[Any]: - """列出指定用户凭证。""" - return self._repository.list_by_user_id(user_id) + def list_by_user_id( + self, + user_id: int, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[Any]: + """按可选数据库窗口列出指定用户凭证。""" + if page is None and count is None: + return self._repository.list_by_user_id(user_id) + return self._repository.list_by_user_id(user_id, page=page, count=count) + + def count_by_user_id(self, user_id: int) -> int: + """返回指定用户启用凭证精确总数。""" + return self._repository.count_by_user_id(user_id) def get_by_credential_id(self, credential_id: str) -> Optional[Any]: """按凭证 ID 查找凭证。""" diff --git a/app/application/security/user.py b/app/application/security/user.py index 89485635a..30a0acd04 100644 --- a/app/application/security/user.py +++ b/app/application/security/user.py @@ -179,8 +179,15 @@ class UserRepository(Protocol): async def async_has_users(self) -> bool: """判断数据库中是否已经存在任意用户。""" - async def async_list(self) -> list[UserSnapshot]: - """返回全部用户。""" + async def async_list( + self, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> list[UserSnapshot]: + """按可选窗口返回用户;两项均省略时返回全部。""" + + async def async_count(self) -> int: + """返回用户总数。""" async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]: """按用户名返回用户。""" @@ -250,9 +257,19 @@ class UserService: self._unit_of_work = unit_of_work self._configuration = configuration - async def list(self) -> list[UserSnapshot]: - """返回用户列表。""" - return await self._repository.async_list() + async def list( + self, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> list[UserSnapshot]: + """按可选数据库窗口返回用户列表。""" + if page is None and count is None: + return await self._repository.async_list() + return await self._repository.async_list(page=page, count=count) + + async def count(self) -> int: + """返回用户精确总数。""" + return await self._repository.async_count() async def is_initialized(self) -> bool: """判断系统是否已经完成首次用户初始化。""" diff --git a/app/application/site/contract.py b/app/application/site/contract.py index 802c43207..d9d4fd72f 100644 --- a/app/application/site/contract.py +++ b/app/application/site/contract.py @@ -332,8 +332,28 @@ class SiteQueryPort(Protocol): """异步读取全部站点快照。""" ... - async def async_list_order_by_pri(self) -> builtins.list[SiteSnapshot]: - """异步按优先级读取站点快照。""" + async def async_list_order_by_pri( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[SiteSnapshot]: + """按筛选和可选分页窗口异步读取站点快照。""" + ... + + async def async_count_sites( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + ) -> int: + """按与站点列表一致的筛选条件统计数量。""" ... async def async_list_active(self) -> builtins.list[SiteSnapshot]: @@ -344,14 +364,32 @@ class SiteQueryPort(Protocol): self, domain: str, workdate: Optional[str] = None, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserDataSnapshot]: - """异步读取指定域名和日期的用户数据快照。""" + """按可选分页窗口异步读取指定域名和日期的用户数据快照。""" + ... + + async def async_count_userdata_by_domain( + self, + domain: str, + workdate: Optional[str] = None, + ) -> int: + """统计指定域名和日期的用户数据数量。""" ... async def async_get_userdata_latest( self, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserDataSnapshot]: - """异步读取各站点最新用户数据快照。""" + """按可选分页窗口异步读取各站点最新用户数据快照。""" + ... + + async def async_count_userdata_latest(self) -> int: + """统计各站点最新用户数据查询的结果数量。""" ... async def async_get_icon_by_domain( @@ -370,8 +408,15 @@ class SiteQueryPort(Protocol): async def async_list_statistics( self, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteStatisticSnapshot]: - """异步读取全部站点健康统计快照。""" + """按可选分页窗口异步读取站点健康统计快照。""" + ... + + async def async_count_statistics(self) -> int: + """统计站点健康统计记录数量。""" ... diff --git a/app/application/site/query.py b/app/application/site/query.py index 190a5660e..583ede175 100644 --- a/app/application/site/query.py +++ b/app/application/site/query.py @@ -16,9 +16,48 @@ class SiteQueryService: """保存站点查询仓储端口。""" self._repository = repository - async def list_ordered(self) -> builtins.list[Site]: - """按站点优先级返回配置 DTO。""" - return [Site.model_validate(item) for item in await self._repository.async_list_order_by_pri()] + async def list_ordered( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[Site]: + """按筛选、优先级和可选分页窗口返回配置 DTO。""" + if all( + value is None + for value in (is_active, name, site_ids, domains, page, count) + ): + items = await self._repository.async_list_order_by_pri() + else: + items = await self._repository.async_list_order_by_pri( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + page=page, + count=count, + ) + return [Site.model_validate(item) for item in items] + + async def count_ordered( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + ) -> int: + """按与站点列表一致的筛选条件返回总数。""" + return await self._repository.async_count_sites( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + ) async def list(self) -> builtins.list[Site]: """返回全部站点配置 DTO。""" @@ -39,24 +78,63 @@ class SiteQueryService: item = await self._repository.async_get_by_domain(domain) return Site.model_validate(item) if item else None - async def userdata_latest(self) -> builtins.list[SiteUserData]: - """返回各站点最新用户数据 DTO。""" - return [SiteUserData.model_validate(item) for item in await self._repository.async_get_userdata_latest()] + async def userdata_latest( + self, + *, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[SiteUserData]: + """按可选分页窗口返回各站点最新用户数据 DTO。""" + if page is None and count is None: + items = await self._repository.async_get_userdata_latest() + else: + items = await self._repository.async_get_userdata_latest( + page=page, + count=count, + ) + return [SiteUserData.model_validate(item) for item in items] + + async def count_userdata_latest(self) -> int: + """返回各站点最新用户数据查询的结果总数。""" + return await self._repository.async_count_userdata_latest() async def userdata( self, domain: str, workdate: Optional[str] = None, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserData]: - """返回指定站点用户数据 DTO。""" - return [ - SiteUserData.model_validate(item) - for item in await self._repository.async_get_userdata_by_domain( + """按可选分页窗口返回指定站点用户数据 DTO。""" + if page is None and count is None: + items = await self._repository.async_get_userdata_by_domain( domain, workdate, ) + else: + items = await self._repository.async_get_userdata_by_domain( + domain, + workdate, + page=page, + count=count, + ) + return [ + SiteUserData.model_validate(item) + for item in items ] + async def count_userdata( + self, + domain: str, + workdate: Optional[str] = None, + ) -> int: + """返回指定站点和日期的用户数据总数。""" + return await self._repository.async_count_userdata_by_domain( + domain, + workdate, + ) + async def icon(self, domain: str) -> Optional[SiteIconData]: """返回站点图标 DTO。""" item = await self._repository.async_get_icon_by_domain(domain) @@ -71,9 +149,25 @@ class SiteQueryService: item = await self._repository.async_get_statistic_by_domain(domain) return SiteStatistic.model_validate(item) if item else SiteStatistic(domain=domain) - async def statistics(self) -> builtins.list[SiteStatistic]: - """返回全部站点统计 DTO。""" - return [SiteStatistic.model_validate(item) for item in await self._repository.async_list_statistics()] + async def statistics( + self, + *, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[SiteStatistic]: + """按可选分页窗口返回站点统计 DTO。""" + if page is None and count is None: + items = await self._repository.async_list_statistics() + else: + items = await self._repository.async_list_statistics( + page=page, + count=count, + ) + return [SiteStatistic.model_validate(item) for item in items] + + async def count_statistics(self) -> int: + """返回站点统计记录总数。""" + return await self._repository.async_count_statistics() def userdata_latest_sync(self) -> builtins.list[SiteUserData]: """同步返回各站点最新用户数据 DTO。""" diff --git a/app/application/subscription/contract.py b/app/application/subscription/contract.py index 8c4122833..fc4eb75da 100644 --- a/app/application/subscription/contract.py +++ b/app/application/subscription/contract.py @@ -383,8 +383,13 @@ class SubscriptionQueryPort(Protocol): """异步按主键读取订阅快照。""" ... - async def async_list(self, state: Optional[str] = None) -> builtins.list[SubscriptionSnapshot]: - """异步按可选状态读取订阅快照。""" + async def async_list( + self, + state: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[SubscriptionSnapshot]: + """异步按可选状态和窗口读取订阅快照。""" ... async def async_list_by_username( @@ -392,8 +397,19 @@ class SubscriptionQueryPort(Protocol): username: str, state: Optional[str] = None, mtype: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SubscriptionSnapshot]: - """异步按用户、状态和类型读取订阅快照。""" + """异步按用户、状态、类型和窗口读取订阅快照。""" + ... + + async def async_count( + self, + state: Optional[str] = None, + username: Optional[str] = None, + mtype: Optional[str] = None, + ) -> int: + """按与公开列表相同的筛选范围返回订阅总数。""" ... async def async_list_by_media_identity( diff --git a/app/application/subscription/query.py b/app/application/subscription/query.py index 0b30d82d1..9b5f51727 100644 --- a/app/application/subscription/query.py +++ b/app/application/subscription/query.py @@ -53,16 +53,39 @@ class SubscriptionQueryService: async def list_public( self, username: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> list[SubscribeView]: - """读取公开订阅列表并转换为稳定 DTO。""" + """按 owner 和可选数据库窗口读取公开订阅 DTO。""" if self._async_repository is None: raise RuntimeError("异步订阅查询端口未注册") if username: - records = await self._async_repository.async_list_by_username(username=username) + if page is None and count is None: + records = await self._async_repository.async_list_by_username( + username=username + ) + else: + records = await self._async_repository.async_list_by_username( + username=username, + page=page, + count=count, + ) else: - records = await self._async_repository.async_list() + if page is None and count is None: + records = await self._async_repository.async_list() + else: + records = await self._async_repository.async_list( + page=page, + count=count, + ) return [SubscribeView.model_validate(record) for record in records] + async def count_public(self, username: Optional[str] = None) -> int: + """按 owner 范围返回公开订阅精确总数。""" + if self._async_repository is None: + raise RuntimeError("异步订阅查询端口未注册") + return await self._async_repository.async_count(username=username) + async def get_public(self, subscribe_id: int) -> Optional[SubscribeView]: """按 ID 读取订阅 DTO。""" if self._async_repository is None: diff --git a/app/application/workflow.py b/app/application/workflow.py index 77397e252..b06db6268 100644 --- a/app/application/workflow.py +++ b/app/application/workflow.py @@ -140,8 +140,26 @@ class WorkflowQueryRepository(Protocol): """读取启用的事件工作流快照。""" ... - async def async_list(self) -> List[WorkflowSnapshot]: - """异步读取全部工作流快照。""" + async def async_list( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[WorkflowSnapshot]: + """按可选筛选与分页窗口异步读取工作流快照。""" + ... + + async def async_count( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + ) -> int: + """按与列表一致的筛选条件统计工作流数量。""" ... async def async_get(self, workflow_id: int) -> Optional[WorkflowSnapshot]: @@ -168,9 +186,42 @@ class WorkflowQueryService: """保存可返回脱离会话快照的查询端口。""" self._repository = repository - async def list(self) -> List[WorkflowSnapshot]: - """返回全部工作流快照。""" - return await self._repository.async_list() + async def list( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[WorkflowSnapshot]: + """按可选筛选与分页窗口返回工作流快照。""" + if all( + value is None + for value in (state, name, trigger_type, page, count) + ): + return await self._repository.async_list() + return await self._repository.async_list( + state=state, + name=name, + trigger_type=trigger_type, + page=page, + count=count, + ) + + async def count( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + ) -> int: + """按与列表一致的筛选条件返回工作流总数。""" + return await self._repository.async_count( + state=state, + name=name, + trigger_type=trigger_type, + ) async def get(self, workflow_id: int) -> Optional[WorkflowSnapshot]: """返回指定工作流快照。""" diff --git a/app/db/adapters/site.py b/app/db/adapters/site.py index 4a3351306..fd0b597ef 100644 --- a/app/db/adapters/site.py +++ b/app/db/adapters/site.py @@ -247,18 +247,52 @@ class TransactionalSiteRepository: return await self._async_read(operation) - async def async_list_order_by_pri(self) -> builtins.list[SiteSnapshot]: - """异步按优先级读取站点快照。""" + async def async_list_order_by_pri( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[SiteSnapshot]: + """按筛选、优先级和可选分页窗口异步读取站点快照。""" async def operation( repository: SiteOper, ) -> builtins.list[SiteSnapshot]: - """读取并在当前异步 Session 中投影排序后的站点。""" - records = await repository.async_list_order_by_pri() + """读取并在当前异步 Session 中投影筛选后的站点。""" + records = await repository.async_list_order_by_pri( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + page=page, + count=count, + ) return [_project_site(item) for item in records] return await self._async_read(operation) + async def async_count_sites( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + ) -> int: + """按与站点列表一致的筛选条件异步统计数量。""" + return await self._async_read( + lambda repository: repository.async_count_sites( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + ) + ) + async def async_list_active(self) -> builtins.list[SiteSnapshot]: """异步读取已启用站点快照。""" @@ -275,8 +309,11 @@ class TransactionalSiteRepository: self, domain: str, workdate: Optional[str] = None, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserDataSnapshot]: - """异步读取指定域名和日期的用户数据快照。""" + """按可选分页窗口异步读取指定域名和日期的用户数据快照。""" async def operation( repository: SiteOper, @@ -285,25 +322,52 @@ class TransactionalSiteRepository: records = await repository.async_get_userdata_by_domain( domain, workdate, + page=page, + count=count, ) return [_project_userdata(item) for item in records] return await self._async_read(operation) + async def async_count_userdata_by_domain( + self, + domain: str, + workdate: Optional[str] = None, + ) -> int: + """异步统计指定域名和日期的用户数据数量。""" + return await self._async_read( + lambda repository: repository.async_count_userdata_by_domain( + domain, + workdate, + ) + ) + async def async_get_userdata_latest( self, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserDataSnapshot]: - """异步读取各站点最新用户数据快照。""" + """按可选分页窗口异步读取各站点最新用户数据快照。""" async def operation( repository: SiteOper, ) -> builtins.list[SiteUserDataSnapshot]: """读取并在当前异步 Session 中投影最新用户数据。""" - records = await repository.async_get_userdata_latest() + records = await repository.async_get_userdata_latest( + page=page, + count=count, + ) return [_project_userdata(item) for item in records] return await self._async_read(operation) + async def async_count_userdata_latest(self) -> int: + """异步统计各站点最新用户数据查询的结果数量。""" + return await self._async_read( + lambda repository: repository.async_count_userdata_latest() + ) + async def async_get_icon_by_domain( self, domain: str, @@ -334,18 +398,30 @@ class TransactionalSiteRepository: async def async_list_statistics( self, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteStatisticSnapshot]: - """异步读取全部站点健康统计快照。""" + """按可选分页窗口异步读取站点健康统计快照。""" async def operation( repository: SiteOper, ) -> builtins.list[SiteStatisticSnapshot]: """读取并在当前异步 Session 中投影全部统计。""" - records = await repository.async_list_statistics() + records = await repository.async_list_statistics( + page=page, + count=count, + ) return [_project_statistic(item) for item in records] return await self._async_read(operation) + async def async_count_statistics(self) -> int: + """异步统计站点健康统计记录数量。""" + return await self._async_read( + lambda repository: repository.async_count_statistics() + ) + def add(self, mutation: SiteMutation) -> SiteWriteResult: """在独立同步事务中新增站点。""" return self._write(lambda repository: SiteWriteResult(*repository.add(**mutation.to_payload()))) @@ -486,11 +562,43 @@ class SessionSiteRepository: records = await self._repository.async_list() return [_project_site(item) for item in records] - async def async_list_order_by_pri(self) -> builtins.list[SiteSnapshot]: - """在请求 Session 中按优先级读取站点快照。""" - records = await self._repository.async_list_order_by_pri() + async def async_list_order_by_pri( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> builtins.list[SiteSnapshot]: + """在请求 Session 中按筛选、优先级和分页窗口读取站点。""" + records = await self._repository.async_list_order_by_pri( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + page=page, + count=count, + ) return [_project_site(item) for item in records] + async def async_count_sites( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[builtins.list[int]] = None, + domains: Optional[builtins.list[str]] = None, + ) -> int: + """在请求 Session 中按列表筛选统计站点数量。""" + return await self._repository.async_count_sites( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + ) + async def async_list_active(self) -> builtins.list[SiteSnapshot]: """在请求 Session 中读取已启用站点快照。""" records = await self._repository.async_list_active() @@ -500,21 +608,47 @@ class SessionSiteRepository: self, domain: str, workdate: Optional[str] = None, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserDataSnapshot]: - """在请求 Session 中读取指定站点用户数据快照。""" + """在请求 Session 中按可选分页窗口读取站点用户数据快照。""" records = await self._repository.async_get_userdata_by_domain( domain, workdate, + page=page, + count=count, ) return [_project_userdata(item) for item in records] + async def async_count_userdata_by_domain( + self, + domain: str, + workdate: Optional[str] = None, + ) -> int: + """在请求 Session 中统计指定站点和日期的用户数据数量。""" + return await self._repository.async_count_userdata_by_domain( + domain, + workdate, + ) + async def async_get_userdata_latest( self, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteUserDataSnapshot]: - """在请求 Session 中读取各站点最新用户数据快照。""" - records = await self._repository.async_get_userdata_latest() + """在请求 Session 中按可选分页窗口读取最新用户数据快照。""" + records = await self._repository.async_get_userdata_latest( + page=page, + count=count, + ) return [_project_userdata(item) for item in records] + async def async_count_userdata_latest(self) -> int: + """在请求 Session 中统计各站点最新用户数据结果数量。""" + return await self._repository.async_count_userdata_latest() + async def async_get_icon_by_domain( self, domain: str, @@ -533,11 +667,21 @@ class SessionSiteRepository: async def async_list_statistics( self, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SiteStatisticSnapshot]: - """在请求 Session 中读取全部站点统计快照。""" - records = await self._repository.async_list_statistics() + """在请求 Session 中按可选分页窗口读取站点统计快照。""" + records = await self._repository.async_list_statistics( + page=page, + count=count, + ) return [_project_statistic(item) for item in records] + async def async_count_statistics(self) -> int: + """在请求 Session 中统计站点健康统计记录数量。""" + return await self._repository.async_count_statistics() + async def stage_create(self, mutation: SiteMutation) -> None: """在请求事务中暂存新增站点。""" await self._repository.stage_create(mutation.to_payload()) diff --git a/app/db/adapters/subscription.py b/app/db/adapters/subscription.py index 3f3a861d6..9f5968bb6 100644 --- a/app/db/adapters/subscription.py +++ b/app/db/adapters/subscription.py @@ -359,14 +359,17 @@ class TransactionalSubscriptionRepository(_TransactionalSubscriptionWriter): async def async_list( self, state: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SubscriptionSnapshot]: - """异步按可选状态读取订阅快照。""" + """异步按可选状态和窗口读取订阅快照。""" async def operation( repository: SubscribeOper, ) -> builtins.list[SubscriptionSnapshot]: """读取并在当前 Session 中投影订阅列表。""" - return [_project_subscription(record) for record in await repository.async_list(state)] + records = await repository.async_list(state, page=page, count=count) + return [_project_subscription(record) for record in records] return await self._async_read(operation) @@ -375,18 +378,41 @@ class TransactionalSubscriptionRepository(_TransactionalSubscriptionWriter): username: str, state: Optional[str] = None, mtype: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SubscriptionSnapshot]: - """异步按用户、状态和类型读取订阅快照。""" + """异步按用户、状态、类型和窗口读取订阅快照。""" async def operation( repository: SubscribeOper, ) -> builtins.list[SubscriptionSnapshot]: """读取并在当前 Session 中投影用户订阅。""" - records = await repository.async_list_by_username(username, state, mtype) + records = await repository.async_list_by_username( + username, + state, + mtype, + page=page, + count=count, + ) return [_project_subscription(record) for record in records] return await self._async_read(operation) + async def async_count( + self, + state: Optional[str] = None, + username: Optional[str] = None, + mtype: Optional[str] = None, + ) -> int: + """按公开列表筛选范围返回订阅精确总数。""" + return await self._async_read( + lambda repository: repository.async_count( + state=state, + username=username, + mtype=mtype, + ) + ) + async def async_list_by_media_identity( self, media_source: MediaSource, @@ -565,9 +591,15 @@ class SessionSubscriptionRepository: async def async_list( self, state: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SubscriptionSnapshot]: - """异步按可选状态读取订阅快照。""" - records = await self._async_repository().async_list(state) + """异步按可选状态和窗口读取订阅快照。""" + records = await self._async_repository().async_list( + state, + page=page, + count=count, + ) return [_project_subscription(record) for record in records] def list_for_reference_rewrite(self) -> builtins.list[SubscriptionSnapshot]: @@ -597,11 +629,32 @@ class SessionSubscriptionRepository: username: str, state: Optional[str] = None, mtype: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> builtins.list[SubscriptionSnapshot]: - """异步按用户、状态和类型读取订阅快照。""" - records = await self._async_repository().async_list_by_username(username, state, mtype) + """异步按用户、状态、类型和窗口读取订阅快照。""" + records = await self._async_repository().async_list_by_username( + username, + state, + mtype, + page=page, + count=count, + ) return [_project_subscription(record) for record in records] + async def async_count( + self, + state: Optional[str] = None, + username: Optional[str] = None, + mtype: Optional[str] = None, + ) -> int: + """按公开列表筛选范围返回订阅精确总数。""" + return await self._async_repository().async_count( + state=state, + username=username, + mtype=mtype, + ) + async def async_list_by_media_identity( self, media_source: MediaSource, @@ -757,6 +810,21 @@ class SessionSubscriptionHistoryRepository: records = await self._repository.async_list_by_type_and_username(mtype, username, page, count) return [_project_history(record) for record in records] + async def async_count_by_type(self, mtype: str) -> int: + """在请求 Session 中统计指定媒体类型的订阅历史。""" + return await self._repository.async_count_by_type(mtype) + + async def async_count_by_type_and_username( + self, + mtype: str, + username: str, + ) -> int: + """在请求 Session 中统计指定类型和用户的订阅历史。""" + return await self._repository.async_count_by_type_and_username( + mtype, + username, + ) + async def stage_delete(self, history_id: int) -> None: """异步暂存删除订阅历史。""" await self._repository.async_delete(history_id) diff --git a/app/db/adapters/user.py b/app/db/adapters/user.py index 5bae5696a..17289d659 100644 --- a/app/db/adapters/user.py +++ b/app/db/adapters/user.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping from contextlib import AbstractAsyncContextManager from typing import Any, Optional, cast -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -75,9 +75,24 @@ class SqlAlchemyUserRepository(UserRepository): result = await session.execute(select(User.id).limit(1)) return result.scalar_one_or_none() is not None - async def async_list(self) -> list[UserSnapshot]: - """在异步请求会话中读取全部冻结用户快照。""" - return [_to_snapshot(model) for model in await self._oper.async_list()] + async def async_list( + self, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> list[UserSnapshot]: + """在异步请求会话中按可选窗口读取冻结用户快照。""" + session = self._require_async_session() + statement = select(User).order_by(User.id) + if page is not None and count is not None: + statement = statement.offset((page - 1) * count).limit(count) + result = await session.execute(statement) + return [_to_snapshot(model) for model in result.scalars().all()] + + async def async_count(self) -> int: + """在异步请求会话中返回用户精确总数。""" + session = self._require_async_session() + result = await session.execute(select(func.count()).select_from(User)) + return int(result.scalar_one()) async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]: """在异步请求会话中按用户名读取冻结快照。""" diff --git a/app/db/adapters/workflow.py b/app/db/adapters/workflow.py index 15a90aba2..a933b42f3 100644 --- a/app/db/adapters/workflow.py +++ b/app/db/adapters/workflow.py @@ -112,12 +112,43 @@ class TransactionalWorkflowQueryRepository: lambda repository: repository.get_event_triggered_workflows() ) - async def async_list(self) -> list[WorkflowSnapshot]: - """在异步短 Session 内投影全部工作流。""" + async def async_list( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> list[WorkflowSnapshot]: + """在异步短 Session 内按筛选和分页窗口投影工作流。""" async with self._async_session() as session: - records = await WorkflowOper(session).async_list() + records = await WorkflowOper(session).async_list( + state=state, + name=name, + trigger_type=trigger_type, + page=page, + count=count, + ) return [_project_workflow(record) for record in records] + async def async_count( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + ) -> int: + """在异步短 Session 内按列表筛选统计工作流数量。""" + async with self._async_session() as session: + return int( + await WorkflowOper(session).async_count( + state=state, + name=name, + trigger_type=trigger_type, + ) + ) + async def async_get(self, workflow_id: int) -> Optional[WorkflowSnapshot]: """在异步短 Session 内读取并投影单条工作流。""" async with self._async_session() as session: diff --git a/app/db/oper/passkey.py b/app/db/oper/passkey.py index 361386101..35850da32 100644 --- a/app/db/oper/passkey.py +++ b/app/db/oper/passkey.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Any, Optional -from sqlalchemy import or_, select, update +from sqlalchemy import func, or_, select, update from sqlalchemy.orm import Session from app.db.base import DbOper, execute_dml @@ -17,16 +17,34 @@ from app.db.models.passkey import ( class PassKeyOper(DbOper): """封装 PassKey 查询和维护,避免 API 层直接引用模型静态方法。""" - def list_by_user_id(self, user_id: int) -> list[PassKey]: - """读取用户启用的 PassKey。""" + def list_by_user_id( + self, + user_id: int, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> list[PassKey]: + """按可选窗口读取用户启用的 PassKey。""" def query(session: Session) -> list[PassKey]: """在调用方会话中读取用户启用的 PassKey。""" - return list(session.execute( - _get_by_user_id_statement(PassKey, user_id) - ).scalars().all()) + statement = _get_by_user_id_statement(PassKey, user_id).order_by(PassKey.id) + if page is not None and count is not None: + statement = statement.offset((page - 1) * count).limit(count) + return list(session.execute(statement).scalars().all()) return self._execute_sync_query(query) + def count_by_user_id(self, user_id: int) -> int: + """返回用户启用 PassKey 的精确总数。""" + return int( + self._execute_sync_query( + lambda session: session.execute( + select(func.count()) + .select_from(PassKey) + .where(PassKey.user_id == user_id, PassKey.is_active.is_(True)) + ).scalar_one() + ) + ) + def list(self) -> list[PassKey]: """读取全部 PassKey,用于判断系统是否已配置通行密钥。""" return self._execute_sync_query( diff --git a/app/db/oper/site.py b/app/db/oper/site.py index d6d7581f4..8315d8f1b 100644 --- a/app/db/oper/site.py +++ b/app/db/oper/site.py @@ -1,7 +1,8 @@ from datetime import datetime -from typing import Any, List, Mapping, Tuple, Optional +from typing import Any, List, Mapping, Optional, Tuple -from sqlalchemy import delete as sqlalchemy_delete, select +from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import false, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -10,6 +11,7 @@ from app.db.models.site import Site from app.db.models.siteicon import SiteIcon from app.db.models.sitestatistic import SiteStatistic from app.db.models.siteuserdata import SiteUserData +from app.db.oper.query import literal_contains async def _async_first(session: AsyncSession, statement: Any) -> Optional[Site]: @@ -18,12 +20,83 @@ async def _async_first(session: AsyncSession, statement: Any) -> Optional[Site]: return result.scalars().first() -async def _async_all(session: AsyncSession, statement: Any) -> list[Site]: - """执行异步站点查询并返回稳定列表。""" +async def _async_all(session: AsyncSession, statement: Any) -> list[Any]: + """执行异步列表查询并返回稳定 ORM 行。""" result = await session.execute(statement) return list(result.scalars().all()) +async def _async_scalar(session: AsyncSession, statement: Any) -> int: + """执行异步计数语句并返回整数。""" + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + +def _apply_page(statement: Any, page: Optional[int], count: Optional[int]) -> Any: + """仅在分页窗口完整时向 SQL 语句追加 LIMIT/OFFSET。""" + if page is None or count is None: + return statement + return statement.offset((page - 1) * count).limit(count) + + +def _site_conditions( + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[list[int]] = None, + domains: Optional[list[str]] = None, +) -> list[Any]: + """构造站点列表与计数共享的数据库筛选条件。""" + conditions: list[Any] = [] + if is_active is not None: + conditions.append(Site.is_active.is_(is_active)) + if name: + conditions.append(literal_contains(Site.name, name)) + if site_ids is not None or domains is not None: + identity_conditions: list[Any] = [] + if site_ids: + identity_conditions.append(Site.id.in_(site_ids)) + if domains: + identity_conditions.append(Site.domain.in_(domains)) + conditions.append( + or_(*identity_conditions) if identity_conditions else false() + ) + return conditions + + +def _userdata_conditions( + domain: str, + workdate: Optional[str], +) -> list[Any]: + """构造站点用户数据列表与计数共享的筛选条件。""" + conditions = [SiteUserData.domain == domain] + if workdate: + conditions.append(SiteUserData.updated_day == workdate) + return conditions + + +def _latest_userdata_subquery() -> Any: + """构造各站点最新有效数据日期的共享子查询。""" + return ( + select( + SiteUserData.domain, + func.max(SiteUserData.updated_day).label("latest_update_day"), + ) + .where(or_(SiteUserData.err_msg.is_(None), SiteUserData.err_msg == "")) + .group_by(SiteUserData.domain) + .subquery() + ) + + +def _latest_userdata_join(statement: Any, subquery: Any) -> Any: + """把最新用户数据日期子查询连接到目标查询语句。""" + return statement.join( + subquery, + (SiteUserData.domain == subquery.c.domain) + & (SiteUserData.updated_day == subquery.c.latest_update_day), + ) + + class SiteOper(DbOper): """ 站点管理 @@ -114,15 +187,53 @@ class SiteOper(DbOper): lambda session: _async_all(session, select(Site)) ) - async def async_list_order_by_pri(self) -> List[Site]: - """异步按优先级获取站点,供站点查询应用服务使用。""" + async def async_list_order_by_pri( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[List[int]] = None, + domains: Optional[List[str]] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[Site]: + """按筛选、优先级和可选分页窗口异步获取站点。""" + statement = select(Site).where( + *_site_conditions( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + ) + ).order_by(Site.pri, Site.id) return await self._execute_async_query( lambda session: _async_all( session, - select(Site).order_by(Site.pri), + _apply_page(statement, page, count), ) ) + async def async_count_sites( + self, + *, + is_active: Optional[bool] = None, + name: Optional[str] = None, + site_ids: Optional[List[int]] = None, + domains: Optional[List[str]] = None, + ) -> int: + """按与站点列表一致的筛选条件异步统计数量。""" + statement = select(func.count()).select_from(Site).where( + *_site_conditions( + is_active=is_active, + name=name, + site_ids=site_ids, + domains=domains, + ) + ) + return await self._execute_async_query( + lambda session: _async_scalar(session, statement) + ) + def list_order_by_pri(self) -> List[Site]: """ 获取站点列表 @@ -317,23 +428,69 @@ class SiteOper(DbOper): ) async def async_get_userdata_by_domain( - self, domain: str, workdate: Optional[str] = None + self, + domain: str, + workdate: Optional[str] = None, + *, + page: Optional[int] = None, + count: Optional[int] = None, ) -> List[SiteUserData]: - """ - 异步获取站点用户数据。 - """ + """按可选分页窗口异步获取站点用户数据。""" + statement = select(SiteUserData).where( + *_userdata_conditions(domain, workdate) + ).order_by( + SiteUserData.updated_day.desc(), + SiteUserData.updated_time.desc(), + SiteUserData.id.desc(), + ) return await self._execute_async_query( - lambda session: SiteUserData.async_get_by_domain( + lambda session: _async_all( session, - domain=domain, - workdate=workdate, + _apply_page(statement, page, count), ) ) - async def async_get_userdata_latest(self) -> List[SiteUserData]: - """异步获取各站点最新用户数据。""" + async def async_count_userdata_by_domain( + self, + domain: str, + workdate: Optional[str] = None, + ) -> int: + """异步统计指定站点和日期的用户数据数量。""" + statement = select(func.count()).select_from(SiteUserData).where( + *_userdata_conditions(domain, workdate) + ) return await self._execute_async_query( - lambda session: SiteUserData.async_get_latest(session) + lambda session: _async_scalar(session, statement) + ) + + async def async_get_userdata_latest( + self, + *, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[SiteUserData]: + """按可选分页窗口异步获取各站点最新用户数据。""" + subquery = _latest_userdata_subquery() + statement = _latest_userdata_join(select(SiteUserData), subquery).order_by( + SiteUserData.updated_time.desc(), + SiteUserData.id.desc(), + ) + return await self._execute_async_query( + lambda session: _async_all( + session, + _apply_page(statement, page, count), + ) + ) + + async def async_count_userdata_latest(self) -> int: + """异步统计各站点最新用户数据查询的结果数量。""" + subquery = _latest_userdata_subquery() + statement = _latest_userdata_join( + select(func.count()).select_from(SiteUserData), + subquery, + ) + return await self._execute_async_query( + lambda session: _async_scalar(session, statement) ) async def async_get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]: @@ -351,9 +508,27 @@ class SiteOper(DbOper): lambda session: SiteStatistic.async_get_by_domain(session, domain) ) - async def async_list_statistics(self) -> List[SiteStatistic]: - """异步获取所有站点统计。""" - return await self._execute_async_query(SiteStatistic.async_list) + async def async_list_statistics( + self, + *, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[SiteStatistic]: + """按可选分页窗口异步获取站点统计。""" + statement = select(SiteStatistic).order_by(SiteStatistic.id) + return await self._execute_async_query( + lambda session: _async_all( + session, + _apply_page(statement, page, count), + ) + ) + + async def async_count_statistics(self) -> int: + """异步统计站点健康统计记录数量。""" + statement = select(func.count()).select_from(SiteStatistic) + return await self._execute_async_query( + lambda session: _async_scalar(session, statement) + ) def get_userdata_by_date(self, date: str) -> List[SiteUserData]: """ diff --git a/app/db/oper/subscribe.py b/app/db/oper/subscribe.py index ba103471e..17a8117f8 100644 --- a/app/db/oper/subscribe.py +++ b/app/db/oper/subscribe.py @@ -10,7 +10,7 @@ import time from collections.abc import Awaitable, Callable, Mapping -from typing import List, Optional, Tuple, cast +from typing import Any, List, Optional, Tuple, cast from sqlalchemy import delete as sqlalchemy_delete from sqlalchemy import func, select @@ -33,6 +33,21 @@ from app.schemas.types import MediaSource INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode") + +async def _async_subscription_rows( + session: AsyncSession, + statement: Any, +) -> List[Subscribe]: + """执行订阅列表语句并返回 ORM 行。""" + result = await session.execute(statement) + return list(result.scalars().all()) + + +async def _async_scalar(session: AsyncSession, statement: Any) -> int: + """执行订阅计数语句并返回整数。""" + result = await session.execute(statement) + return int(result.scalar_one()) + AfterCommitEffect = Callable[[int], None] AsyncAfterCommitEffect = Callable[[int], Awaitable[None]] @@ -602,36 +617,68 @@ class SubscribeOper(DbOper): """ return cast(List[Subscribe], self._execute_sync_query(lambda session: Subscribe.get_by_state(session, state))) - async def async_list(self, state: Optional[str] = None) -> List[Subscribe]: - """ - 异步获取订阅列表 - """ + async def async_list( + self, + state: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[Subscribe]: + """按可选状态和数据库窗口异步获取订阅列表。""" + statement = select(Subscribe).order_by(Subscribe.id) if state: - return cast( - List[Subscribe], - await self._execute_async_query(lambda session: Subscribe.async_get_by_state(session, state)), - ) - return cast(List[Subscribe], await self._execute_async_query(Subscribe.async_list)) + statement = statement.where(Subscribe.state.in_(state.split(","))) + if page is not None and count is not None: + statement = statement.offset((page - 1) * count).limit(count) + return cast( + List[Subscribe], + await self._execute_async_query( + lambda session: _async_subscription_rows(session, statement) + ), + ) async def async_list_by_username( self, username: str, state: Optional[str] = None, mtype: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, ) -> List[Subscribe]: - """异步按用户获取订阅。""" + """按用户筛选和数据库窗口异步获取订阅。""" + statement = select(Subscribe).where(Subscribe.username == username).order_by(Subscribe.id) + if state: + statement = statement.where(Subscribe.state == state) + if mtype: + statement = statement.where(Subscribe.type == mtype) + if page is not None and count is not None: + statement = statement.offset((page - 1) * count).limit(count) return cast( List[Subscribe], await self._execute_async_query( - lambda session: Subscribe.async_list_by_username( - session, - username=username, - state=state, - mtype=mtype, - ) + lambda session: _async_subscription_rows(session, statement) ), ) + async def async_count( + self, + state: Optional[str] = None, + username: Optional[str] = None, + mtype: Optional[str] = None, + ) -> int: + """按公开列表筛选条件返回订阅精确总数。""" + statement = select(func.count()).select_from(Subscribe) + if state: + statement = statement.where(Subscribe.state.in_(state.split(","))) + if username: + statement = statement.where(Subscribe.username == username) + if mtype: + statement = statement.where(Subscribe.type == mtype) + return int( + await self._execute_async_query( + lambda session: _async_scalar(session, statement) + ) + ) + async def async_list_by_title( self, title: str, diff --git a/app/db/oper/workflow.py b/app/db/oper/workflow.py index 8c2a6725b..bfe75eb30 100644 --- a/app/db/oper/workflow.py +++ b/app/db/oper/workflow.py @@ -1,10 +1,45 @@ from typing import Any, List, Mapping, Optional, Tuple from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.db.base import DbOper from app.db.models.workflow import Workflow +from app.db.oper.query import literal_contains + + +def _workflow_conditions( + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, +) -> list[Any]: + """构造工作流列表与计数共享的数据库筛选条件。""" + conditions: list[Any] = [] + if state: + conditions.append(Workflow.state == state) + if name: + conditions.append(literal_contains(Workflow.name, name)) + if trigger_type == "timer": + conditions.append( + or_(Workflow.trigger_type == "timer", Workflow.trigger_type.is_(None)) + ) + elif trigger_type: + conditions.append(Workflow.trigger_type == trigger_type) + return conditions + + +async def _async_workflow_rows(session: Any, statement: Any) -> List[Workflow]: + """执行工作流列表语句并返回 ORM 行。""" + result = await session.execute(statement) + return list(result.scalars().all()) + + +async def _async_scalar(session: Any, statement: Any) -> int: + """执行工作流计数语句并返回整数。""" + result = await session.execute(statement) + return int(result.scalar_one() or 0) class WorkflowOper(DbOper): @@ -70,12 +105,46 @@ class WorkflowOper(DbOper): """ return self._execute_sync_query(lambda session: Workflow.list(session)) - async def async_list(self) -> List[Workflow]: - """ - 异步获取所有工作流列表 - """ + async def async_list( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + page: Optional[int] = None, + count: Optional[int] = None, + ) -> List[Workflow]: + """按筛选条件和可选分页窗口异步获取工作流列表。""" + statement = select(Workflow).where( + *_workflow_conditions( + state=state, + name=name, + trigger_type=trigger_type, + ) + ).order_by(Workflow.id) + if page is not None and count is not None: + statement = statement.offset((page - 1) * count).limit(count) return await self._execute_async_query( - lambda session: Workflow.async_list(session) + lambda session: _async_workflow_rows(session, statement) + ) + + async def async_count( + self, + *, + state: Optional[str] = None, + name: Optional[str] = None, + trigger_type: Optional[str] = None, + ) -> int: + """按与列表一致的筛选条件异步统计工作流数量。""" + statement = select(func.count()).select_from(Workflow).where( + *_workflow_conditions( + state=state, + name=name, + trigger_type=trigger_type, + ) + ) + return await self._execute_async_query( + lambda session: _async_scalar(session, statement) ) def list_enabled(self) -> List[Workflow]: diff --git a/app/locales/en-US.json b/app/locales/en-US.json index b49bc9c7a..c44044f95 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -201,6 +201,7 @@ "用户未通过认证,无法使用站点功能!": "User authentication has not passed, so site features cannot be used.", "该站点不支持,请检查站点域名是否正确": "This site is not supported. Please check whether the site domain is correct", "站点不存在": "Site does not exist", + "站点 {site} 未配置域名": "Site {site} does not have a configured domain", "CookieCloud同步任务已启动!": "CookieCloud sync task has started!", "站点已重置!": "Site has been reset!", "站点不支持索引或未通过用户认证!": "The site does not support indexing or user authentication has not passed!", @@ -498,6 +499,10 @@ "source": "站点 {site} 不存在", "target": "Site {site} does not exist" }, + { + "source": "站点 {site} 未配置域名", + "target": "Site {site} does not have a configured domain" + }, { "source": "站点 {site} 不支持", "target": "Site {site} is not supported" diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json index 62c20240d..fc01646c4 100644 --- a/app/locales/zh-CN.json +++ b/app/locales/zh-CN.json @@ -120,6 +120,7 @@ "storage_type 无效": "storage_type 无效", "媒体服务器请求失败": "媒体服务器请求失败", "不支持的媒体类型": "不支持的媒体类型", + "站点 {site} 未配置域名": "站点 {site} 未配置域名", "豆瓣网络连接失败": "豆瓣网络连接失败", "Bangumi网络连接失败": "Bangumi网络连接失败", "fanart网络连接失败": "fanart网络连接失败", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index da3bf5986..7e1fad86d 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -197,6 +197,7 @@ "用户未通过认证,无法使用站点功能!": "使用者未通過認證,無法使用站點功能!", "该站点不支持,请检查站点域名是否正确": "不支援此站點,請檢查站點網域是否正確", "站点不存在": "站點不存在", + "站点 {site} 未配置域名": "站點 {site} 未設定網域", "CookieCloud同步任务已启动!": "CookieCloud 同步任務已啟動!", "站点已重置!": "站點已重設!", "站点不支持索引或未通过用户认证!": "站點不支援索引或未通過使用者認證!", @@ -492,6 +493,10 @@ "source": "站点 {site} 不存在", "target": "站點 {site} 不存在" }, + { + "source": "站点 {site} 未配置域名", + "target": "站點 {site} 未設定網域" + }, { "source": "站点 {site} 不支持", "target": "站點 {site} 不支援" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index de21d3a6a..b06c04b74 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -755,7 +755,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 919 | -| 内部导入边 | 7,684 | +| 内部导入边 | 7,688 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/agent-tool-refactor-plan.md b/docs/architecture/agent-tool-refactor-plan.md index a2c724d5f..d2b9dc3fb 100644 --- a/docs/architecture/agent-tool-refactor-plan.md +++ b/docs/architecture/agent-tool-refactor-plan.md @@ -1,10 +1,10 @@ # MoviePilot Agent 工具体系重构计划 -> 状态:COMPLETE — L10 查询 API 兼容分页与总数合同已验证 +> 状态:IN PROGRESS — L10 本地验证完成,等待提交推送与远端 CI > > 建立日期:2026-08-31 > -> 当前基线:v3@871632af257a663abc159c516acc027a81ab2314 +> 当前基线:v3@91e33277f3a05dc10b8984a2e612d8a32441c987 > > 关联目标:本线程已建立的 Agent 工具体系重构 Goal @@ -40,8 +40,8 @@ | 下载器/媒体服务器 | 依赖内置低层 Agent 工具或有限 REST operation | downloader-operation 与 mediaserver-operation Skill 通过固定脚本调用已配置 provider API | | MCP/HTTP 工具管理 | 存在旧业务工具与同名 first-wins 选择空间 | 与主 Agent 共用严格唯一新目录;重名直接以 TOOL_IDENTITY_AMBIGUOUS 失败 | | 退役代码 | 旧实现仍位于 app/agent/tools/impl | 77 个退役文件已直接删除,其中 72 个工具模块、5 个辅助模块 | -| 架构图 | 982 个宿主模块、8,430 条内部依赖边 | 919 个宿主模块、7,680 条内部依赖边,Application/Chain 具体 Adapter 直连仍为 0 | -| 工作区状态 | 基线提交 871632af,与 origin/v3 对齐,初始工作区干净 | 最终提交 `3106984df` 已推送并与 `origin/v3` 对齐;生产改动、生成合同、静态检查、全量测试、固定 80% 覆盖率和远端 CI 均已通过 | +| 架构图 | 982 个宿主模块、8,430 条内部依赖边 | 919 个宿主模块、7,688 条内部依赖边,Application/Chain 具体 Adapter 直连仍为 0 | +| 工作区状态 | 基线提交 871632af,与 origin/v3 对齐,初始工作区干净 | L1-L9 已完成;L10 正在把上一轮响应层兼容分页改为端点显式输入和数据库查询下推,当前基线 `91e33277f` 与 `origin/v3` 对齐 | ## 3. 目标工具分层 @@ -134,7 +134,7 @@ provider 动态返回 namespaced action、参数约束、副作用等级及是 | L7 第三方服务 Skill 化 | VERIFIED | L5 | 下载器和媒体服务器能力发现、受控脚本、Skill、策略和离线测试完成;重复低层 Agent API operation 已删除 | | L8 收口与交付 | VERIFIED | L6,L7 | 旧代码、文档与架构基线已收口;静态检查和锁定全量测试已完成 | | L9 全 API 面审计与最终交付 | VERIFIED | L8 | 375 个 OpenAPI 操作逐路由归属、203 个网关合同与 72 个退出工具映射均由测试锁定;全量测试、80% 覆盖率门禁、提交推送和远端 CI 终态均已完成 | -| L10 查询 API 兼容分页与总数合同 | VERIFIED | L9 | 列表查询均可报告当前返回数量;原完整列表新增可选分页,省略新参数时继续返回全部原始结果并报告精确总数;响应 `data` 列表结构不变;外部原生分页未提供总数时不强制输出;OpenAPI、MCP、Skill、审计和测试同步完成 | +| L10 查询 API 显式分页与数据库下推 | ACTIVE | L9 | 列表端点显式声明分页输入;数据库筛选在 `LIMIT/OFFSET` 前完成并使用同条件精确 `COUNT`;省略新增参数保持旧返回语义;响应 `data` 列表结构不变;外部原生分页未提供总数时不伪造;OpenAPI、MCP、Skill、审计、80% 覆盖率和远端 CI 同步完成 | ## 5. L2 受控 API 网关约束 @@ -309,7 +309,7 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API - 将历史删除提交中的 72 个业务工具冻结为替代映射测试,逐项证明其 owner 是 203-operation API、下载器/媒体服务器 action 或统一 `agent_task` / `persona` 原生工具,并确认旧模块物理文件不存在 - 实际执行数据库脚本 `tables` / `schema` / `SELECT 1`、下载器与媒体服务器 `instances` / `capabilities`,并直接运行三个结构化 service tool;本机未配置 provider 实例时返回空实例而不是配置读取错误 - 通过临时本地 HTTP 服务实际执行 `MoviePilotApiTool -> MoviePilotApiExecutor -> GET /api/v1/site/agent` 完整链路,验证返回成功且普通用户投影不包含 cookie、API key、token 或 RSS 等认证字段 -- 修复完整 API Skill 超过原 64 KiB 运行时返回上限而被截断的问题:结果上限调整为 256 KiB,并以真实内置 Skill 加载测试确认 203 个 operation 均可见且 `truncated=false` +- 新增内置 Agent 私有 `read_skill` 工具,绕过普通工具 64 KiB 结果裁剪并单独限制 `SKILL.md` 主体为 512 KiB;工具同时返回全部辅助文件相对路径,超过上限时明确标记截断,真实内置 Skill 加载测试确认 203 个 operation 均可见且 `truncated=false` - Skill 生成器同步 YAML `allowed-api-operations` 与正文目录,避免“文档有参数但运行时未授权”的双事实源漂移;MCP schema、Skill front matter、正文和注册表数量及集合完全一致 - 为站点优先级、插件目录和工作流路径 ID 等原先不精确的输入补充类型模型或端点约束;固定路由占位符与 path schema 名称、required 状态由测试逐项校验 - 受影响 Agent/Skill/MCP/OpenAPI/音乐/架构回归 247 passed;修复全量发现的工作流管理员门禁、插件分页默认值、模块命名治理、服务工具标签和 Schema 导出清单后,专项回归 40 passed @@ -333,15 +333,20 @@ action,并使用 MoviePilot 已配置的具体服务实例访问其自身 API ### 2026-09-01:L10 查询 API 兼容分页与总数合同 -- 重新开启父目标并进入 L10;当前 `v3` 与 `origin/v3` 对齐,工作区同时存在维护者的 Transfer 领域未提交修改,本阶段避开这些文件并只提交 Agent/API 合同相关改动 +- 重新开启父目标并进入 L10;当前基线 `91e33277f` 与 `origin/v3` 对齐,初始工作区干净 - 查询 API 按结果语义分为完整列表、原生分页或限量列表、结构化分页对象、统计或聚合对象;只有列表结果进入统一数量合同,统计、映射和时序对象不为形式统一而错误分页 - 原完整列表新增的 `page` / `count` 必须都是可选参数;两者都省略时不切片,继续返回端点原先的完整列表。显式传入任一参数才启用分页,缺失的 `page` 按 1、缺失的 `count` 按 50 解释 - REST 响应继续保持 `Response.data` 为原列表,禁止改成 `{items,total}` 等对象;总数和分页信息使用响应头及 Agent 网关附加元数据表达,避免破坏外部插件和既有客户端 - 对完整列表可报告切片前精确总数;对已经由第三方来源原生分页或限量、且上游没有提供总数的结果,只报告当前返回数量,不强制增加总数,也禁止把当前页数量伪装成全局总数 -- 统一响应路由已为原完整列表注入可选 Query 参数:`page >= 1`、`1 <= count <= 200`;两者均省略时不分页,显式提供任一参数后,缺失的 `page` 使用 1、缺失的 `count` 使用 50。已有 `page/count`、`limit/offset` 或 `max_results` 的端点继续使用自己的原始参数和默认值 +- 第一版由统一响应路由隐式注入分页参数并在序列化后切片;复核后确认这种实现虽然能生成查询参数,但端点签名不自描述,而且数据库列表仍会全量读取,因此不作为最终方案 +- 当前正式方案由每个列表端点显式声明 `page` / `count`;框架 `Response` 仅用于写响应头,不会出现在 OpenAPI、MCP 或 Skill 输入中。已有第三方原生 `page`、`count`、`limit` 或 `max_results` 的接口保留其既有参数与默认语义 +- 用户、PassKey、活动订阅、Workflow、Site、站点用户数据和站点统计的筛选、稳定排序、`LIMIT/OFFSET` 与精确 `COUNT` 已下推到异步 SQLAlchemy 查询;状态、用户名、名称、触发类型、站点启用状态、站点 ID/域名和日期筛选均在分页前执行,避免空页和错误总数 +- 纯内存、配置、缓存、文件系统或运行时目录仍可在响应边界按显式 `page/count` 切片;第三方原生分页且不返回总数的接口只报告当前页数量,不伪造总数 - REST 保持 `data` 原列表;`X-Result-Count` 报告本次返回数量,精确可知时增加 `X-Total-Count`,Agent 网关把这些响应头映射到附加 `collection` 对象。下载历史、订阅历史和插件目录已增加本地精确计数;外部媒体、音乐、推荐和搜索来源未提供总数时不输出 `total_count` -- OpenAPI 与 `api_mcp_schema.json` 已同步完整参数、默认值、范围和集合输出合同,英文 `skills/moviepilot-api/SKILL.md` 已重新生成;结构化分页端点继续使用既有 `data.total` 与 `data.items` / `data.list` -- 验证完成:定向回归 226 项通过;全量四分片合计 7634 项通过、9 项跳过;固定 80% 覆盖率门禁通过(Application 81.87%,Domain 81.01%);Ruff、Mypy、架构基线与差异检查均通过 -- 验证期间安全快进到最新 `v3` 提交 `b2e3056ca`,其网络修复与本阶段文件无冲突;快进后的关键回归再次通过 +- OpenAPI 与 `api_mcp_schema.json` 已重新生成,英文 `skills/moviepilot-api/SKILL.md` 已同步显式参数;生成器会识别插件目录的既有 `max_results`,不会错误声称其默认返回全量 +- 完整锁定测试已通过:4 个分片分别为 `1620 passed, 3 skipped`、`1860 passed, 4 skipped`、`1917 passed`、`2248 passed, 2 skipped`,合计 `7645 passed, 9 skipped` +- 覆盖率按 CI 相同的 8 分片采集并合并,Application `81.72%`、Domain `81.01%`,通过固定 80% 门禁;Ruff/mypy ratchet、严格 mypy、复杂度 v1/v2、并发、异步阻塞、TaskRegistry owner、服务定位、事件策略和启动性能门禁均通过 +- 数据库 Oper 复用 `literal_contains` 新增 4 条内部依赖边,宿主快照从 7,684 更新为 7,688;没有新增 Application/Chain 到具体 Adapter 的直连或跨层依赖 +- 待完成:执行最终 Pylint 与生成物一致性检查,提交推送并等待最终远端 CI 成功后再把 L10 标记为 VERIFIED 本文件作为本次重构的持续记录,保留阶段状态、实际变更、验证结果、提交状态与已知基线边界。 diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index b6194932e..ab025fd0e 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 919 / 7,684 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 919 / 7,688 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,8 +102,8 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,605 / 517 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | -| Ruff 历史诊断 | 574 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 9,603 / 517 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| Ruff 历史诊断 | 571 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/cli.md b/docs/cli.md index cc5f8cff7..b6c3fa08d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -559,9 +559,12 @@ moviepilot tool run moviepilot_api operation_id=media.search 'query={"title":" - `tool run` 参数格式固定为 `key=value`;对象、数组、布尔值和 `null` 使用合法 JSON - MoviePilot 业务能力统一通过 `moviepilot_api` 的固定 `operation_id` 调用;不接受任意 URL、method、认证头或 Token,也不兼容旧业务工具名 - 涉及精确媒体身份的 operation 统一使用 `media_source` + `media_id`,两个字段必须成对传递并复用搜索结果 -- `read_file`、`write_file`、`edit_file` 和 `execute_command` +- `read_skill`、`read_file`、`write_file`、`edit_file` 和 `execute_command` 属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时 由 Agent 按当前用户权限直接调用这些工具。 +- `read_skill` 会一次性返回最多 512 KiB 的 `SKILL.md` 和技能目录内其它文件的完整相对路径列表, + 不应用普通工具结果的 64 KiB 全局大小裁剪;超出时会明确标记截断,Agent 不应再用 + `read_file` 读取或绕过 `SKILL.md` 限制。 - `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用 `start_line`、`end_line` 指定更小的行号范围继续读取。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 63f1d7924..21b679eda 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -95,7 +95,8 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确 查询结果的兼容分页合同如下: -- 原先返回完整列表、没有分页参数的接口会在 OpenAPI、Skill 和 MCP `oneOf` 中新增可选 `page` / `count`;`page` 必须不小于 1,`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才切片,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。 +- 原先返回完整列表、没有分页参数的接口会在端点签名、OpenAPI、Skill 和 MCP `oneOf` 中显式声明可选 `page` / `count`;`page` 必须不小于 1,`count` 范围为 1 到 200。两者都省略时仍返回原来的完整列表,不启用分页;显式传入任一参数时才分页,缺失的 `page` 按 1、缺失的 `count` 按 50 处理。FastAPI 的 `response` 注入对象不是业务输入,不会出现在 REST、Skill 或 MCP 参数中。 +- 数据库列表在查询层先应用授权范围和业务筛选,再执行稳定排序、`LIMIT/OFFSET` 和同条件精确 `COUNT`;不得先全表加载、响应后切片。纯内存、配置、缓存、文件系统或运行时列表可以在序列化边界切片。已有 `max_results` 等原生限量参数的接口继续保留其旧默认值,显式 `page/count` 的优先级由端点合同说明。 - REST 响应的 `data` 保持原列表结构,不改成 `{items,total}`。`X-Result-Count` 报告本次实际返回数量;仅当 MoviePilot 已经取得完整筛选结果时,才增加精确的 `X-Total-Count`。原有结构化分页接口继续在既有 `data.total` 与 `data.items` / `data.list` 中返回总数。 - `moviepilot_api` 把这些响应头投影为响应中的附加 `collection` 对象:`result_count` 为本次返回数量,`total_count` 仅在精确可知时出现,`page` / `count` 在可用时出现。`collection` 是附加元数据,不替换或改写 `data`。 - 已经由第三方接口原生分页或限量、但上游没有返回总数的查询不会伪造 `total_count`;Agent 应以 `result_count` 判断当前页是否为空,并按原接口的分页参数继续读取。 @@ -440,7 +441,7 @@ MCP、HTTP 工具管理接口、本地 CLI 和内置 Agent 都从同一严格目 | `browse_webpage`、`recognize_captcha` | 浏览和验证码等非 MoviePilot 业务 API 能力 | | `query_doctor_report` | 只读系统诊断 | -`read_file`、`write_file`、`edit_file`、`apply_patch`、`execute_command` 和 +`read_skill`、`read_file`、`write_file`、`edit_file`、`apply_patch`、`execute_command` 和 `search_web` 不通过 MCP 暴露。隐藏列表只负责收敛接口暴露面,不替代各工具自身的 权限、路径和网络边界。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 27424a5ac..668ad5973 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -696,7 +696,7 @@ Purpose: Install or update one plugin from an approved source. Purpose: List installed plugins and their runtime status. - `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. - `path_params`: none -- `query`: `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. +- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. - `body`: none ### `plugin.market` @@ -704,7 +704,7 @@ Purpose: List installed plugins and their runtime status. Purpose: List plugins available from configured marketplaces. - `response`: `data` remains a list and the endpoint's documented pagination or limit defaults remain in effect. `collection.result_count` reports the returned items and `collection.total_count` reports the exact total. - `path_params`: none -- `query`: `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries. +- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer; default `50`; minimum `1`; maximum `200`): Maximum number of plugin catalog results to return, from 1 to 200.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=market): Literal market, selecting only market plugin catalog entries. - `body`: none ### `plugin.market.sync_wiki` diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index de9112a08..8c9fbdffc 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 7684, - "edge_sha256": "62be4cd0bd17892f3679d8ffd108b54d6c886413290adb9c22e794b076084e03", + "edge_count": 7688, + "edge_sha256": "3fd19c9e83839d9fca024010ca2826658af311b1d8cc5f19c8539c259da4f7ec", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -5326,6 +5326,8 @@ "app.db.oper.site -> app.db.models.siteicon", "app.db.oper.site -> app.db.models.sitestatistic", "app.db.oper.site -> app.db.models.siteuserdata", + "app.db.oper.site -> app.db.oper", + "app.db.oper.site -> app.db.oper.query", "app.db.oper.subscribe -> app.application", "app.db.oper.subscribe -> app.application.subscription", "app.db.oper.subscribe -> app.application.subscription.contract", @@ -5395,6 +5397,8 @@ "app.db.oper.workflow -> app.db.base", "app.db.oper.workflow -> app.db.models", "app.db.oper.workflow -> app.db.models.workflow", + "app.db.oper.workflow -> app.db.oper", + "app.db.oper.workflow -> app.db.oper.query", "app.db.session -> app.db", "app.db.session -> app.db.engine", "app.db.session -> app.runtime", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 28eb5eafc..2013dd9ea 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -535,12 +535,12 @@ "type-arg": 14 }, "app/api/endpoints/site.py": { - "arg-type": 7, + "arg-type": 6, "assignment": 2, "attr-defined": 3, "misc": 26, "no-untyped-def": 2, - "return-value": 3, + "return-value": 2, "type-arg": 6, "var-annotated": 1 }, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 8f126a76e..6d2344242 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -138,9 +138,6 @@ "app/db/oper/message.py": { "I001": 1 }, - "app/db/oper/site.py": { - "I001": 1 - }, "app/db/oper/user.py": { "I001": 1 }, @@ -692,9 +689,6 @@ "tests/test_api_authorization.py": { "I001": 1 }, - "tests/test_api_response.py": { - "I001": 1 - }, "tests/test_async_db_pooling.py": { "I001": 1 }, @@ -1061,9 +1055,6 @@ "tests/test_transfer_download_history_oper_sessions.py": { "I001": 1 }, - "tests/test_transfer_failed_retry_budget.py": { - "I001": 1 - }, "tests/test_transfer_history_retransfer.py": { "I001": 1 }, diff --git a/tests/test_agent_api_projection_endpoints.py b/tests/test_agent_api_projection_endpoints.py index 9c951d6e3..a97ffb583 100644 --- a/tests/test_agent_api_projection_endpoints.py +++ b/tests/test_agent_api_projection_endpoints.py @@ -12,8 +12,15 @@ from app.schemas.file import FileItem class _SiteQuery: """Return a stable mixed site list for projection tests.""" - async def list_ordered(self): - """Return one active and one inactive site with authentication fields.""" + async def list_ordered( + self, + *, + is_active=None, + name=None, + page=None, + count=None, + ): + """Apply the endpoint's database-query contract to two fixed sites.""" common = { "domain": "example.invalid", "url": "https://example.invalid/", @@ -34,18 +41,34 @@ class _SiteQuery: "apikey": "secret-key", "token": "secret-token", } - return [ + sites = [ SimpleNamespace(id=1, name="Active Site", is_active=True, **common), SimpleNamespace(id=2, name="Inactive Site", is_active=False, **common), ] + if is_active is not None: + sites = [site for site in sites if site.is_active is is_active] + if name: + sites = [site for site in sites if name.lower() in site.name.lower()] + if page is not None and count is not None: + offset = (page - 1) * count + sites = sites[offset:offset + count] + return sites class _WorkflowQuery: """Return workflows with private action context that the Agent projection must omit.""" - async def list(self): - """Return one manual running workflow and one timer workflow.""" - return [ + async def list( + self, + *, + state=None, + name=None, + trigger_type=None, + page=None, + count=None, + ): + """Apply the endpoint's database-query contract to two fixed workflows.""" + workflows = [ SimpleNamespace( id=1, name="Manual Workflow", @@ -77,6 +100,24 @@ class _WorkflowQuery: result=None, ), ] + if state: + workflows = [workflow for workflow in workflows if workflow.state == state] + if name: + workflows = [ + workflow + for workflow in workflows + if name.lower() in workflow.name.lower() + ] + if trigger_type: + workflows = [ + workflow + for workflow in workflows + if workflow.trigger_type == trigger_type + ] + if page is not None and count is not None: + offset = (page - 1) * count + workflows = workflows[offset:offset + count] + return workflows def test_site_agent_projection_filters_and_hides_secrets_for_normal_users() -> None: diff --git a/tests/test_agent_background_output.py b/tests/test_agent_background_output.py index 4031c88de..5221f47a7 100644 --- a/tests/test_agent_background_output.py +++ b/tests/test_agent_background_output.py @@ -452,7 +452,7 @@ class TestAgentBackgroundOutput: ] async def test_create_agent_registers_skill_tool_from_middleware(self): - """SkillsMiddleware 暴露的 skill 工具应进入 Agent 工具和筛选候选。""" + """SkillsMiddleware 暴露的 read_skill 应进入 Agent 工具和筛选候选。""" captured = {} skill_tool = SimpleNamespace(name=SKILL_TOOL_NAME) agent = MoviePilotAgent(session_id="normal-session", user_id="system") diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index 76e837db8..f30f267e1 100644 --- a/tests/test_agent_skills_middleware.py +++ b/tests/test_agent_skills_middleware.py @@ -9,11 +9,12 @@ from langchain.agents.middleware.types import ModelRequest from langchain_core.messages import SystemMessage from app.agent.middleware.skills import ( - MAX_SKILL_RESULT_CHARS, + MAX_SKILL_CONTENT_BYTES, SKILL_TOOL_NAME, SkillsMiddleware, _alist_skills, ) +from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS from app.agent.tools.tags import ToolTag PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -60,22 +61,28 @@ async def test_alist_skills_sorts_skill_directories_by_name(tmp_path): assert ["a-skill", "m-skill", "z-skill"] == [skill["id"] for skill in skills] -def test_skills_middleware_exposes_skill_tool(tmp_path): - """SkillsMiddleware 应以中间件工具形式暴露 skill。""" +def test_skills_middleware_exposes_read_skill_tool(tmp_path): + """SkillsMiddleware 应以中间件私有工具形式暴露 read_skill。""" _write_skill(tmp_path, "moviepilot-api") middleware = SkillsMiddleware(sources=[str(tmp_path)]) assert [tool.name for tool in middleware.tools] == [SKILL_TOOL_NAME] + assert SKILL_TOOL_NAME == "read_skill" assert ToolTag.Read in middleware.tools[0].tags assert ToolTag.Skill in middleware.tools[0].tags assert "moviepilot-api" in middleware.tools[0].description @pytest.mark.anyio -async def test_skill_tool_loads_skill_by_id_and_name(tmp_path): - """skill 工具应支持按 id 或 name 加载完整 SKILL.md。""" +async def test_read_skill_loads_body_and_supporting_files_by_id_and_name(tmp_path): + """read_skill 应按 id 或 name 返回完整主体及稳定的辅助文件清单。""" _write_skill(tmp_path, "moviepilot-api", name="MoviePilot API") + skill_dir = tmp_path / "moviepilot-api" + (skill_dir / "references").mkdir() + (skill_dir / "references" / "usage.md").write_text("usage", encoding="utf-8") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.py").write_text("print('ok')", encoding="utf-8") middleware = SkillsMiddleware(sources=[str(tmp_path)]) skill_tool = middleware.tools[0] @@ -86,26 +93,36 @@ async def test_skill_tool_loads_skill_by_id_and_name(tmp_path): assert by_id["skill"]["id"] == "moviepilot-api" assert "# moviepilot-api" in by_id["content"] assert by_id["skill"]["allowed_api_operations"] == [] + assert by_id["supporting_files"] == [ + "references/usage.md", + "scripts/run.py", + ] + assert by_id["truncated"] is False assert by_name["success"] is True assert by_name["skill"]["name"] == "MoviePilot API" @pytest.mark.anyio -async def test_skill_tool_caps_large_result_before_model_context(tmp_path): - """超大 Skill 内容应在工具返回前限制到模型上下文上限。""" +async def test_read_skill_caps_body_at_512_kib_without_global_64_kib_truncation(tmp_path): + """read_skill 应绕过普通裁剪,但将技能主体限制为 512 KiB。""" _write_skill(tmp_path, "large-skill") skill_path = tmp_path / "large-skill" / "SKILL.md" + final_marker = "END-OF-LARGE-SKILL" with skill_path.open("a", encoding="utf-8") as file_handle: - file_handle.write("\n" + ("large-line\n" * 30000)) + file_handle.write("\n" + ("large-line\n" * 120000) + final_marker) middleware = SkillsMiddleware(sources=[str(tmp_path)]) result = await middleware.tools[0].ainvoke({"name": "large-skill"}) payload = json.loads(result) - assert len(result) <= MAX_SKILL_RESULT_CHARS + assert len(result) > DEFAULT_TOOL_RESULT_MAX_CHARS assert payload["success"] is True + assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES + assert len(payload["content"].encode("utf-8")) <= MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is True - assert "Skill 内容已截断" in payload["content"] + assert "512 KiB" in payload["truncation_message"] + assert final_marker not in payload["content"] + assert payload["supporting_files"] == [] @pytest.mark.anyio @@ -116,16 +133,17 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None: result = await middleware.tools[0].ainvoke({"name": "moviepilot-api"}) payload = json.loads(result) - assert len(result) <= MAX_SKILL_RESULT_CHARS assert payload["success"] is True + assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is False + assert payload["truncation_message"] is None assert len(payload["skill"]["allowed_api_operations"]) == 203 assert "### `workflow.update`" in payload["content"] @pytest.mark.anyio async def test_skill_tool_returns_not_found_for_unknown_skill(tmp_path): - """skill 工具找不到技能时应返回结构化失败信息。""" + """read_skill 找不到技能时应返回结构化失败信息。""" middleware = SkillsMiddleware(sources=[str(tmp_path)]) skill_tool = middleware.tools[0] @@ -233,8 +251,8 @@ async def test_skill_operation_scope_allows_declared_api_operation(tmp_path): handler.assert_awaited_once_with(request) -def test_modify_request_instructs_model_to_use_skill_tool_without_paths(tmp_path): - """系统提示应要求通过 skill 工具加载,而不是直接暴露文件读取路径。""" +def test_modify_request_instructs_model_to_use_read_skill_without_paths(tmp_path): + """系统提示应要求用 read_skill 加载主体,而不是 read_file 或裸路径。""" _write_skill(tmp_path, "moviepilot-api") middleware = SkillsMiddleware(sources=[str(tmp_path)]) skills_metadata = middleware._load_skills_metadata() @@ -249,15 +267,17 @@ def test_modify_request_instructs_model_to_use_skill_tool_without_paths(tmp_path modified = middleware.modify_request(request) system_content = str(modified.system_message.content) - assert "`skill` tool" in system_content + assert "`read_skill` tool" in system_content + assert "never `read_file`" in system_content + assert "up to 512 KiB" in system_content assert "moviepilot-api" in system_content assert "Read `" not in system_content assert str(tmp_path) not in system_content @pytest.mark.anyio -async def test_skill_tool_call_records_streaming_summary(tmp_path): - """skill 工具执行时应记录流式聚合摘要。""" +async def test_read_skill_tool_call_records_streaming_summary(tmp_path): + """read_skill 工具执行时应记录流式聚合摘要。""" _write_skill(tmp_path, "moviepilot-api") calls = [] stream_handler = SimpleNamespace( diff --git a/tests/test_agent_summarization_streaming.py b/tests/test_agent_summarization_streaming.py index 030df7e7c..93190147d 100644 --- a/tests/test_agent_summarization_streaming.py +++ b/tests/test_agent_summarization_streaming.py @@ -301,11 +301,11 @@ def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares(): "edit_file", "execute_command", "agent_task", - "skill", + "read_skill", ] assert tool_selector_middleware.selection_tools[: len(fake_tools)] == fake_tools assert [getattr(tool, "name", None) for tool in tool_selector_middleware.selection_tools[len(fake_tools) :]] == [ - "skill" + "read_skill" ] diff --git a/tests/test_agent_tool_streaming.py b/tests/test_agent_tool_streaming.py index 86b498267..f88f17659 100644 --- a/tests/test_agent_tool_streaming.py +++ b/tests/test_agent_tool_streaming.py @@ -306,13 +306,13 @@ class TestAgentToolStreaming: await handler.start_streaming() handler.emit("处理中:") handler.record_tool_call( - tool_name="skill", - tool_message="Loads the full instructions for a MoviePilot skill", + tool_name="read_skill", + tool_message="Reads a MoviePilot skill", tool_kwargs={"name": "moviepilot-api"}, ) handler.record_tool_call( - tool_name="skill", - tool_message="Loads the full instructions for a MoviePilot skill", + tool_name="read_skill", + tool_message="Reads a MoviePilot skill", tool_kwargs={"name": "moviepilot-api"}, ) handler.record_tool_call( diff --git a/tests/test_api_response.py b/tests/test_api_response.py index 5c7b1ea2f..5fe13b2ce 100644 --- a/tests/test_api_response.py +++ b/tests/test_api_response.py @@ -22,6 +22,8 @@ from app.api.response import ( COLLECTION_PAGINATION_OPENAPI_KEY, COLLECTION_TOTAL_OPENAPI_KEY, RAW_RESPONSE_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, ResponseAPIRoute, ResponseAPIRouter, ) @@ -104,7 +106,10 @@ def api_app() -> FastAPI: return [Item(id=1)] @app.get("/many-items", response_model=list[Item]) - async def get_many_items() -> list[Item]: + async def get_many_items( + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, + ) -> list[Item]: """返回用于验证兼容分页行为的完整业务列表。""" return [Item(id=index) for index in range(1, 6)] @@ -293,8 +298,8 @@ def test_collection_openapi_declares_optional_compatibility_parameters_and_heade } -def test_every_host_collection_route_declares_compatible_count_contract(): - """所有宿主列表接口必须报告当前数量,并区分缺省全量与原生分页。""" +def test_every_host_collection_route_declares_explicit_pagination_contract(): + """所有宿主列表接口必须显式声明分页窗口并报告当前数量。""" app = FastAPI() from app.api.apiv1 import api_router @@ -322,19 +327,52 @@ def test_every_host_collection_route_declares_compatible_count_contract(): ) ) ) - if not compatible_method or has_native_window: + if not compatible_method: continue query_parameters = { parameter["name"]: parameter["schema"] for parameter in operation.get("parameters", []) if parameter.get("in") == "query" } + assert "response" not in query_parameters, prefix + if has_native_window: + continue + assert {"page", "count"}.issubset(endpoint_parameters), prefix assert {"page", "count"}.issubset(query_parameters), prefix assert "default" not in query_parameters["page"], prefix assert "default" not in query_parameters["count"], prefix assert "X-Total-Count" in headers, prefix +def test_database_collection_routes_expose_explicit_page_count_without_response_input(): + """数据库列表接口只公开 page/count,框架 Response 不能泄漏成 Agent 输入。""" + app = FastAPI() + from app.api.apiv1 import api_router + + app.include_router(api_router, prefix="/api/v1") + openapi = app.openapi() + paths = ( + "/api/v1/user/", + "/api/v1/mfa/passkey/list", + "/api/v1/subscribe/", + "/api/v1/site/", + "/api/v1/workflow/", + ) + + for path in paths: + operation = openapi["paths"][path]["get"] + parameters = { + parameter["name"]: parameter + for parameter in operation.get("parameters", []) + if parameter.get("in") == "query" + } + assert {"page", "count"}.issubset(parameters), path + assert "response" not in parameters, path + assert "default" not in parameters["page"]["schema"], path + assert "default" not in parameters["count"]["schema"], path + assert "X-Total-Count" in operation["responses"]["200"]["headers"], path + + async def test_explicit_none_and_stream_keep_native_protocol(api_app: FastAPI): """显式无响应模型和流式响应应保持原生协议。""" async with make_client(api_app) as client: diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 7b77d9d4e..289d7019b 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -540,7 +540,10 @@ def test_workflow_query_contract_returns_only_typed_snapshots(): for method in methods: annotation = ast.unparse(method.returns) assert "Any" not in annotation - assert "WorkflowSnapshot" in annotation + if method.name in {"count", "async_count"}: + assert annotation == "int" + else: + assert "WorkflowSnapshot" in annotation def test_workflow_query_consumers_do_not_reach_raw_oper(): diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index fe1b95f33..5d88451ac 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -135,6 +135,15 @@ def test_retired_moviepilot_cli_skill_is_removed() -> None: assert not (SKILLS_ROOT / "moviepilot-update" / "scripts" / "mp-update.py").exists() +def test_core_prompt_requires_read_skill_for_skill_documents() -> None: + """核心提示必须阻止模型用 read_file 分段读取 SKILL.md。""" + core_prompt = CORE_PROMPT_PATH.read_text(encoding="utf-8") + + assert "Always use `read_skill`, never `read_file`, to load a skill's SKILL.md" in core_prompt + assert "returns up to 512 KiB of the skill body" in core_prompt + assert "do not use `read_file` to bypass the limit" in core_prompt + + def test_every_retired_business_tool_has_a_live_precise_owner() -> None: """全部 72 个退出业务工具必须由 API、provider Skill 或统一原生工具承接。""" from app.agent.policy.api import API_OPERATION_ROUTES diff --git a/tests/test_db_config_user_queries.py b/tests/test_db_config_user_queries.py index cc22e11cd..5196a3278 100644 --- a/tests/test_db_config_user_queries.py +++ b/tests/test_db_config_user_queries.py @@ -304,6 +304,20 @@ def test_passkey_oper_queries_use_explicit_session(db, monkeypatch): assert oper.get_by_credential_id("cred-oper-inactive") is None +def test_passkey_oper_paginates_and_counts_active_credentials(db): + """PassKey 列表只统计有效凭据,并在数据库查询中稳定分页。""" + first = db.add(_passkey(9002, "cred-page-1")) + second = db.add(_passkey(9002, "cred-page-2")) + db.add(_passkey(9002, "cred-page-off", is_active=False)) + oper = PassKeyOper(db.session) + + page = oper.list_by_user_id(9002, page=2, count=1) + + assert oper.count_by_user_id(9002) == 2 + assert [item.id for item in page] == [second.id] + assert first.id < second.id + + def test_passkey_lookup_by_credential_id_skips_inactive(db): """ 按凭据 ID 查找同样必须忽略停用记录,否则停用的密钥仍可完成认证。 diff --git a/tests/test_db_site_queries.py b/tests/test_db_site_queries.py index 80ed55e11..57653fe74 100644 --- a/tests/test_db_site_queries.py +++ b/tests/test_db_site_queries.py @@ -83,6 +83,34 @@ def test_site_list_order_by_pri_is_ascending(db): ["p1.test", "p2.test", "p3.test"] +def test_site_oper_filters_paginates_and_counts_in_database(db): + """站点筛选必须在 LIMIT/OFFSET 前完成,计数使用同一组条件。""" + first = db.add(_site("Agent分页一", "agent-page-1.test", pri=1)) + second = db.add(_site("Agent分页二", "agent-page-2.test", pri=2)) + db.add( + _site("Agent分页停用", "agent-page-off.test", pri=3, is_active=False), + _site("无关站点", "unrelated.test", pri=4), + ) + + async def query(session): + """在同一异步会话中执行筛选计数和第二页查询。""" + oper = SiteOper(session) + total = await oper.async_count_sites(is_active=True, name="Agent分页") + rows = await oper.async_list_order_by_pri( + is_active=True, + name="Agent分页", + page=2, + count=1, + ) + return total, [item.id for item in rows] + + total, ids = db.run_async_session(query) + + assert total == 2 + assert ids == [second.id] + assert first.id < second.id + + def test_site_get_domains_by_ids_returns_plain_strings(db): """ 按 ID 批量取域名必须返回纯字符串列表,且只含请求的那些 ID。 @@ -160,6 +188,29 @@ def test_sitestatistic_reset_empties_the_table(db): assert SiteStatistic.get_by_domain(db.session, "stat-reset.test") is None +def test_site_statistics_oper_paginates_and_counts(db): + """站点统计列表在数据库中分页,并返回未分页前的精确总数。""" + first = db.add( + SiteStatistic(domain="stat-page-1.test", success=1, fail=0, seconds=1) + ) + second = db.add( + SiteStatistic(domain="stat-page-2.test", success=2, fail=0, seconds=1) + ) + + async def query(session): + """读取统计总数和第二页。""" + oper = SiteOper(session) + total = await oper.async_count_statistics() + rows = await oper.async_list_statistics(page=2, count=1) + return total, [item.id for item in rows] + + total, ids = db.run_async_session(query) + + assert total >= 2 + assert ids == [second.id] + assert first.id < second.id + + # --------------------------------------------------------------------------- # # SiteUserData # --------------------------------------------------------------------------- # diff --git a/tests/test_db_subscribe_queries.py b/tests/test_db_subscribe_queries.py index 9624fc936..c399978cb 100644 --- a/tests/test_db_subscribe_queries.py +++ b/tests/test_db_subscribe_queries.py @@ -14,6 +14,7 @@ from app.db import base as db_base from app.db.models import subscribe as subscribe_module from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory +from app.db.oper.subscribe import SubscribeOper from app.db.session import async_session_scope from app.schemas.types import MediaSource, MediaType @@ -76,6 +77,34 @@ def test_exists_matches_async_twin(db): assert sync_found.id == async_found.id +def test_subscribe_oper_filters_paginates_and_counts_in_database(db): + """订阅 owner 与状态筛选必须先进入 SQL,再按同一条件计数和分页。""" + first = db.add(_sub("分页一", media_id="page-1", username="alice", state="N")) + second = db.add(_sub("分页二", media_id="page-2", username="alice", state="N")) + db.add( + _sub("其他用户", media_id="page-3", username="bob", state="N"), + _sub("其他状态", media_id="page-4", username="alice", state="R"), + ) + + async def query(session): + """在同一异步会话中执行订阅计数和第二页查询。""" + oper = SubscribeOper(session) + total = await oper.async_count(state="N", username="alice") + rows = await oper.async_list_by_username( + "alice", + state="N", + page=2, + count=1, + ) + return total, [item.id for item in rows] + + total, ids = db.run_async_session(query) + + assert total == 2 + assert ids == [second.id] + assert first.id < second.id + + def test_history_queries_reuse_explicit_sessions(db, monkeypatch): """订阅历史同步/异步查询必须复用调用方会话。""" row = db.add(_history("显式历史", media_id="8501")) diff --git a/tests/test_db_workflow_queries.py b/tests/test_db_workflow_queries.py index 37d1e6c16..80f4a77a2 100644 --- a/tests/test_db_workflow_queries.py +++ b/tests/test_db_workflow_queries.py @@ -135,6 +135,41 @@ def test_query_repository_async_projection_survives_session_close(db): assert created.id in {item.id for item in listed} +def test_query_repository_filters_paginates_and_counts_in_database(db): + """Agent 工作流筛选必须先进入 SQL,再按同一条件计数和分页。""" + first = db.add(_flow("agent-page-first", trigger_type=None, state="W")) + second = db.add(_flow("agent-page-second", trigger_type="timer", state="W")) + db.add( + _flow("agent-page-paused", trigger_type="timer", state="P"), + _flow("unrelated-workflow", trigger_type="timer", state="W"), + ) + repository = TransactionalWorkflowQueryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ) + + total = asyncio.run( + repository.async_count( + state="W", + name="agent-page-", + trigger_type="timer", + ) + ) + page = asyncio.run( + repository.async_list( + state="W", + name="agent-page-", + trigger_type="timer", + page=2, + count=1, + ) + ) + + assert total == 2 + assert [item.id for item in page] == [second.id] + assert first.id < second.id + + def test_workflow_snapshot_validates_against_api_response_contract(db): """冻结快照可直接序列化为 API 合同且不会暴露内部执行上下文。""" created = db.add(_flow("wf-api-snapshot")) diff --git a/tests/test_mcp_plugin_tools.py b/tests/test_mcp_plugin_tools.py index 7f1ab01a6..6ec3bef69 100644 --- a/tests/test_mcp_plugin_tools.py +++ b/tests/test_mcp_plugin_tools.py @@ -117,3 +117,26 @@ def test_direct_manager_rejects_ambiguous_tool_identity() -> None: manager.get_strict_tool("demo_plugin_tool") with pytest.raises(RuntimeError, match="TOOL_IDENTITY_AMBIGUOUS"): asyncio.run(manager.call_tool("demo_plugin_tool", {})) + + +def test_read_skill_is_hidden_from_mcp_calls() -> None: + """read_skill 只属于内置 Agent,中间件工具不得通过 MCP 直接调用。""" + builtin_names = { + MoviePilotToolFactory._tool_class_name(tool_class) + for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES + } + + assert "read_skill" in mcp.MCP_HIDDEN_TOOLS + assert "read_skill" not in builtin_names + + result = asyncio.run( + mcp.handle_tools_call( + { + "name": "read_skill", + "arguments": {"name": "moviepilot-api"}, + } + ) + ) + + assert result["isError"] is True + assert "未找到" in result["content"][0]["text"] diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 0ee5adc43..f5c564810 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -260,6 +260,37 @@ def test_market_endpoint_reads_source_preserving_candidates_for_bound_update(): plugin_manager.async_get_online_plugin_candidates.assert_awaited_once_with(False) +def test_all_plugins_explicit_page_count_overrides_legacy_max_results() -> None: + """插件列表显式 page/count 应分页,省略时仍保留旧 max_results 行为。""" + catalog = MagicMock() + catalog.query = AsyncMock( + return_value=[ + schemas.Plugin(id=f"Plugin{index}", plugin_version="1.0.0") + for index in range(1, 4) + ] + ) + + with patch( + "app.api.endpoints.plugin.get_plugin_catalog_query", + return_value=catalog, + ): + response = Response() + result = asyncio.run( + plugin_endpoint.all_plugins( + None, + "all", + False, + max_results=1, + page=2, + count=1, + response=response, + ) + ) + + assert [plugin.id for plugin in result] == ["Plugin2"] + assert response.headers["X-Total-Count"] == "3" + + def _persistence(identity: PluginIdentity) -> MagicMock: """构造只暴露身份读取合同的异步持久化替身。""" persistence = MagicMock() diff --git a/tests/test_site_media_filter.py b/tests/test_site_media_filter.py index 0ab2be6cc..a8dd3f4e5 100644 --- a/tests/test_site_media_filter.py +++ b/tests/test_site_media_filter.py @@ -50,7 +50,18 @@ def test_read_sites_by_media_type_filters_configured_active_sites(monkeypatch, m {"id": 4, "category": {}}, {"id": 5, "media_type": "music"}, ] - list_sites = AsyncMock(return_value=sites) + async def list_sites_from_query(**filters): + """模拟站点查询端口在数据库层应用启用状态和站点标识筛选。""" + site_ids = set(filters["site_ids"]) + domains = set(filters["domains"]) + return [ + site + for site in sites + if site.is_active is filters["is_active"] + and (site.id in site_ids or site.domain in domains) + ] + + list_sites = AsyncMock(side_effect=list_sites_from_query) get_indexers = AsyncMock(return_value=indexers) monkeypatch.setattr( site_endpoint, @@ -66,7 +77,13 @@ def test_read_sites_by_media_type_filters_configured_active_sites(monkeypatch, m ) assert [site.id for site in result] == expected_ids - list_sites.assert_awaited_once() + list_sites.assert_awaited_once_with( + is_active=True, + site_ids=expected_ids if media_type != "music" else [2, 3, 5], + domains=[], + page=None, + count=None, + ) get_indexers.assert_awaited_once() diff --git a/tests/test_user_repository.py b/tests/test_user_repository.py index 8479995a0..fd32fbbd2 100644 --- a/tests/test_user_repository.py +++ b/tests/test_user_repository.py @@ -97,6 +97,25 @@ def _insert_user(sync_factory, **overrides) -> int: return user.id +@pytest.mark.asyncio +async def test_user_repository_paginates_and_counts_in_database(tmp_path) -> None: + """用户列表应按主键稳定分页,并以独立 COUNT 返回完整总数。""" + async with _user_write_context(tmp_path / "pagination.db") as (session, _): + users = [ + User(name=f"page-user-{index}", email=f"{index}@example.com") + for index in range(1, 4) + ] + session.add_all(users) + await session.commit() + repository = SqlAlchemyUserRepository(session) + + page = await repository.async_list(page=2, count=1) + + assert await repository.async_count() == 3 + assert [item.id for item in page] == [users[1].id] + assert users[0].id < users[1].id < users[2].id + + @asynccontextmanager async def _user_write_context(database_path): """创建包含用户聚合表的异步请求会话。"""