diff --git a/app/adapters/system/host.py b/app/adapters/system/host.py index dac1ed04f..39ec41ee0 100644 --- a/app/adapters/system/host.py +++ b/app/adapters/system/host.py @@ -25,6 +25,16 @@ from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryI from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo from version import APP_VERSION +from app.foundation.environment import ( + is_aarch, + is_aarch64, + is_docker, + is_frozen, + is_macos, + is_windows, + is_x86_32, + is_x86_64, +) # Linux amd64/arm64 UAPI: _IOR(BTRFS_IOCTL_MAGIC, 31, struct btrfs_ioctl_fs_info_args) @@ -142,7 +152,7 @@ class SystemUtils: """ 判断是否为Docker环境 """ - return Path("/.dockerenv").exists() + return is_docker() @staticmethod def is_synology() -> bool: @@ -158,50 +168,49 @@ class SystemUtils: """ 判断是否为Windows系统 """ - return os.name == "nt" + return is_windows() @staticmethod def is_frozen() -> bool: """ 判断是否为冻结的二进制文件 """ - return getattr(sys, 'frozen', False) + return is_frozen() @staticmethod def is_macos() -> bool: """ 判断是否为MacOS系统 """ - return platform.system() == 'Darwin' + return is_macos() @staticmethod def is_aarch64() -> bool: """ 判断是否为ARM64架构 """ - return platform.machine().lower() in ('aarch64', 'arm64') + return is_aarch64() @staticmethod def is_aarch() -> bool: """ 判断是否为ARM32架构 """ - arch_name = platform.machine().lower() - return arch_name.startswith(('arm', 'aarch')) and arch_name not in ('aarch64', 'arm64') + return is_aarch() @staticmethod def is_x86_64() -> bool: """ 判断是否为AMD64架构 """ - return platform.machine().lower() in ('amd64', 'x86_64') + return is_x86_64() @staticmethod def is_x86_32() -> bool: """ 判断是否为AMD32架构 """ - return platform.machine().lower() in ('i386', 'i686', 'x86', '386', 'x86_32') + return is_x86_32() @staticmethod def platform() -> str: @@ -224,14 +233,13 @@ class SystemUtils: """ if SystemUtils.is_x86_64(): return "x86_64" - elif SystemUtils.is_x86_32(): + if SystemUtils.is_x86_32(): return "x86_32" - elif SystemUtils.is_aarch64(): + if SystemUtils.is_aarch64(): return "Arm64" - elif SystemUtils.is_aarch(): + if SystemUtils.is_aarch(): return "Arm32" - else: - return platform.machine() + return platform.machine() @staticmethod def copy(src: Path, dest: Path) -> Tuple[int, str]: @@ -895,16 +903,14 @@ class SystemUtils: """ 获取配置路径 """ - if not config_dir: - config_dir = os.getenv("CONFIG_DIR") - if config_dir: - return Path(config_dir) + configured = config_dir or os.getenv("CONFIG_DIR") + if configured: + return Path(configured) if SystemUtils.is_docker(): return Path("/config") - elif SystemUtils.is_frozen(): + if SystemUtils.is_frozen(): return Path(sys.executable).parent / "config" - else: - return Path(__file__).resolve().parents[3] / "config" + return Path(__file__).resolve().parents[3] / "config" @staticmethod def get_env_path() -> Path: diff --git a/app/adapters/web/security/__init__.py b/app/adapters/web/security/__init__.py new file mode 100644 index 000000000..dfa7b6279 --- /dev/null +++ b/app/adapters/web/security/__init__.py @@ -0,0 +1 @@ +"""Web 传输层认证适配器。""" diff --git a/app/adapters/web/security/access.py b/app/adapters/web/security/access.py new file mode 100644 index 000000000..77fb54498 --- /dev/null +++ b/app/adapters/web/security/access.py @@ -0,0 +1,267 @@ +"""把应用安全能力适配为 FastAPI 认证依赖和 Cookie 行为。""" + +import datetime +from datetime import timedelta +from typing import Annotated, Any, Callable, Optional + +import jwt +from fastapi import HTTPException, Request, Response, Security, status +from fastapi.security import ( + APIKeyCookie, + APIKeyHeader, + APIKeyQuery, + HTTPBearer, + OAuth2PasswordBearer, +) + +from app.runtime.cache import cached +from app.runtime.config import settings +from app.runtime.log import logger +from app.schemas.token import TokenPayload + +SuperuserTokenPayloadProvider = Callable[[], TokenPayload] +TokenEncoder = Callable[..., str] +TokenDecoder = Callable[[str | None, str], TokenPayload] +_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None +_token_encoder: Optional[TokenEncoder] = None +_token_decoder: Optional[TokenDecoder] = None +JWT_ALGORITHM = "HS256" + +oauth2_scheme_manual_error = OAuth2PasswordBearer( + auto_error=False, + tokenUrl=f"{settings.API_V1_STR}/login/access-token", +) +resource_token_cookie = APIKeyCookie( + name=settings.PROJECT_NAME, + auto_error=False, + scheme_name="resource_token_cookie", +) +api_token_query = APIKeyQuery( + name="token", + auto_error=False, + scheme_name="api_token_query", +) +api_key_header = APIKeyHeader( + name="X-API-KEY", + auto_error=False, + scheme_name="api_key_header", +) +api_key_query = APIKeyQuery( + name="apikey", + auto_error=False, + scheme_name="api_key_query", +) +openai_bearer_scheme = HTTPBearer(auto_error=False) +anthropic_api_key_header = APIKeyHeader( + name="x-api-key", + auto_error=False, + scheme_name="anthropic_api_key_header", +) + + +def set_superuser_token_payload_provider( + provider: SuperuserTokenPayloadProvider, +) -> None: + """由启动组合根注入 API 密钥认证使用的超级用户载荷来源。""" + global _superuser_token_payload_provider + _superuser_token_payload_provider = provider + + +def configure_token_codec( + encoder: TokenEncoder, + decoder: TokenDecoder, +) -> None: + """由组合根注入框架无关的令牌编码与解码能力。""" + global _token_encoder, _token_decoder + _token_encoder = encoder + _token_decoder = decoder + + +def _encode_token(**claims: Any) -> str: + """使用已注入编码器创建令牌,未装配时给出明确错误。""" + if _token_encoder is None: + raise RuntimeError("Web 认证令牌编码器尚未配置") + return _token_encoder(**claims) + + +def _decode_token(token: str | None, purpose: str) -> TokenPayload: + """使用已注入解码器验证令牌,未装配时给出明确错误。""" + if _token_decoder is None: + raise RuntimeError("Web 认证令牌解码器尚未配置") + return _token_decoder(token, purpose) + + +def _get_api_token( + token_query: Annotated[str | None, Security(api_token_query)] = None, +) -> str | None: + """从 URL 查询参数读取兼容 API Token。""" + return token_query + + +def _get_api_key( + key_query: Annotated[str | None, Security(api_key_query)] = None, + key_header: Annotated[str | None, Security(api_key_header)] = None, +) -> str | None: + """优先从请求头、其次从查询参数读取兼容 API Key。""" + return key_header or key_query + + +@cached(maxsize=1, ttl=600) +def _create_superuser_token_payload() -> TokenPayload: + """使用组合根提供器创建 API 密钥调用的超级用户载荷。""" + if not _superuser_token_payload_provider: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="认证服务尚未初始化", + ) + try: + return _superuser_token_payload_provider() + except PermissionError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(error) or "用户权限不足", + ) from error + + +def set_or_refresh_resource_token_cookie( + request: Request, + response: Response, + payload: TokenPayload, +) -> None: + """复用匹配的资源令牌,或为当前身份写入新的安全 Cookie。""" + resource_token = request.cookies.get(settings.PROJECT_NAME) + if resource_token: + try: + decoded = jwt.decode( + resource_token, + settings.RESOURCE_SECRET_KEY, + algorithms=[JWT_ALGORITHM], + ) + exp = decoded.get("exp") + if exp: + remaining_time = datetime.datetime.fromtimestamp( + exp, + tz=datetime.UTC, + ) - datetime.datetime.now(datetime.UTC) + if remaining_time < timedelta( + seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3 + ): + raise jwt.ExpiredSignatureError + expected_claims = { + "sub": str(payload.sub), + "username": payload.username, + "super_user": payload.super_user, + "level": payload.level, + "purpose": "resource", + } + if any( + decoded.get(claim) != value + for claim, value in expected_claims.items() + ): + raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配") + except jwt.PyJWTError: + logger.debug("Token error occurred. refreshing token") + except Exception as error: + logger.debug( + f"Unexpected error occurred while decoding token: {error}" + ) + else: + return + + resource_token = _encode_token( + userid=payload.sub, + username=payload.username or "", + super_user=payload.super_user, + expires_delta=timedelta( + seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS + ), + level=payload.level, + purpose="resource", + ) + is_https = ( + request.url.scheme == "https" + or request.headers.get("x-forwarded-proto", "").lower() == "https" + ) + response.set_cookie( + key=settings.PROJECT_NAME, + value=resource_token, + httponly=True, + secure=is_https, + samesite="lax", + ) + + +def _decode_or_http_error( + token: str | None, + purpose: str, +) -> TokenPayload: + """把应用层令牌校验错误转换为 HTTP 403。""" + try: + return _decode_token(token, purpose) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(error), + ) from error + + +def verify_token( + request: Request, + response: Response, + jwt_token: Annotated[ + str | None, + Security(oauth2_scheme_manual_error), + ], + api_key: Annotated[str | None, Security(_get_api_key)], + api_token: Annotated[str | None, Security(_get_api_token)], +) -> TokenPayload: + """验证 JWT、API Key 或 API Token,并维护资源 Cookie。""" + if jwt_token: + payload = _decode_or_http_error(jwt_token, "authentication") + set_or_refresh_resource_token_cookie(request, response, payload) + return payload + if api_key: + verify_apikey(api_key) + return _create_superuser_token_payload() + if api_token: + verify_apitoken(api_token) + return _create_superuser_token_payload() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def verify_resource_token( + resource_token: Annotated[ + str | None, + Security(resource_token_cookie), + ], +) -> TokenPayload: + """验证 Cookie 中携带的资源访问令牌。""" + return _decode_or_http_error(resource_token, "resource") + + +def _verify_key(key: str | None, expected_key: str, key_type: str) -> str: + """校验受信第三方集成使用的固定 API 凭据。""" + if not key or key != expected_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"{key_type} 校验不通过", + ) + return key + + +def verify_apitoken( + token: Annotated[str | None, Security(_get_api_token)], +) -> str: + """校验 URL 查询参数中的兼容 API Token。""" + return _verify_key(token, settings.API_TOKEN, "token") + + +def verify_apikey( + apikey: Annotated[str | None, Security(_get_api_key)], +) -> str: + """校验请求头或查询参数中的兼容 API Key。""" + return _verify_key(apikey, settings.API_TOKEN, "apikey") diff --git a/app/agent/llm/__init__.py b/app/agent/llm/__init__.py index 32c2be776..4845c742b 100644 --- a/app/agent/llm/__init__.py +++ b/app/agent/llm/__init__.py @@ -1,24 +1,7 @@ """Agent 内部使用的 LLM 适配层,公开对象按需解析。""" from importlib import import_module -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from app.agent.llm.capability import ( - AgentCapabilityManager, - AgentCapabilityProvider, - AudioCapabilityProvider, - MiMoAudioProvider, - OpenAIAudioProvider, - OpenAIChatAudioProvider, - ) - from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout - from app.agent.llm.provider import ( - LLMProviderAuthError, - LLMProviderError, - LLMProviderManager, - render_auth_result_html, - ) +from typing import Any _EXPORT_MODULES = { diff --git a/app/agent/llm/capability.py b/app/agent/llm/capability.py index 536994220..4ba797ecf 100644 --- a/app/agent/llm/capability.py +++ b/app/agent/llm/capability.py @@ -799,7 +799,7 @@ class AgentCapabilityManager: if not source: return False - from app.runtime.extensions.service_registry import ServiceConfigHelper + from app.runtime.extensions.service_config import ServiceConfigHelper for config in ServiceConfigHelper.get_notification_configs(): if config.name != source: diff --git a/app/agent/llm/gateway.py b/app/agent/llm/gateway.py new file mode 100644 index 000000000..3d1418c93 --- /dev/null +++ b/app/agent/llm/gateway.py @@ -0,0 +1,49 @@ +"""LLM helper 与 provider 实现之间的运行时端口。""" + +from collections.abc import Callable +from typing import Any, Protocol + + +class LLMProviderRuntimePort(Protocol): + """声明 LLM helper 所需的最小 provider 运行时能力。""" + + def resolve_cached_model_metadata(self, **kwargs: Any) -> dict[str, Any] | None: + """从本地目录缓存解析模型元数据。""" + ... + + async def resolve_runtime(self, **kwargs: Any) -> dict[str, Any]: + """解析创建模型客户端所需的统一运行时参数。""" + ... + + def create_bedrock_client(self, *args: Any, **kwargs: Any) -> Any: + """创建带统一认证和网络配置的 Bedrock 客户端。""" + ... + + async def list_models(self, **kwargs: Any) -> list[dict[str, Any]]: + """返回 provider 可用的模型目录。""" + ... + + def resolve_model_list_base_url(self, **kwargs: Any) -> str | None: + """解析兼容接口用于查询模型列表的基础地址。""" + ... + + +LLMProviderRuntimeFactory = Callable[[], LLMProviderRuntimePort] +_provider_runtime_factory: LLMProviderRuntimeFactory | None = None + + +def register_llm_provider_runtime( + factory: LLMProviderRuntimeFactory | None, +) -> LLMProviderRuntimeFactory | None: + """注册 provider 运行时工厂,并返回先前工厂供隔离测试恢复。""" + global _provider_runtime_factory + previous = _provider_runtime_factory + _provider_runtime_factory = factory + return previous + + +def resolve_llm_provider_runtime() -> LLMProviderRuntimePort: + """解析已组装的 provider 运行时,未注册时给出明确边界错误。""" + if _provider_runtime_factory is None: + raise RuntimeError("LLM provider 运行时尚未由启动层完成组装") + return _provider_runtime_factory() diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index f9c5ae06c..d7837a4e2 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -10,6 +10,7 @@ from urllib.parse import urlsplit from langchain_core.messages import AIMessage, AIMessageChunk +from app.agent.llm.gateway import resolve_llm_provider_runtime from app.runtime.config import settings from app.runtime.log import logger @@ -793,9 +794,7 @@ class LLMHelper: return None try: - from app.agent.llm.provider import LLMProviderManager - - metadata = LLMProviderManager().resolve_cached_model_metadata( + metadata = resolve_llm_provider_runtime().resolve_cached_model_metadata( provider_id=provider_name, model_id=model_name, base_url=base_url if base_url is not None else settings.LLM_BASE_URL, @@ -1197,11 +1196,7 @@ class LLMHelper: thinking_level=thinking_level, ) try: - # 延迟导入,避免单测在最小 stub 环境下 import `llm.py` 时被 provider - # 目录依赖链拖住。 - from app.agent.llm.provider import LLMProviderManager - - runtime = await LLMProviderManager().resolve_runtime( + runtime = await resolve_llm_provider_runtime().resolve_runtime( provider_id=provider_name, model=model_name, api_key=api_key_value, @@ -1328,8 +1323,6 @@ class LLMHelper: elif runtime["runtime"] == "bedrock": from langchain_aws import ChatBedrockConverse - from app.agent.llm.provider import LLMProviderManager - bedrock_model_cls = ChatBedrockConverse if ( str(prompt_cache_key or "").strip() @@ -1344,7 +1337,7 @@ class LLMHelper: aws_auth = runtime.get("aws_auth") or {} # Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证 # 直接以 AK/SK 签名;两种方式统一由 provider 管理器构造 boto3 客户端。 - bedrock_client = LLMProviderManager().create_bedrock_client( + bedrock_client = resolve_llm_provider_runtime().create_bedrock_client( "bedrock-runtime", region=aws_region, credentials=aws_auth, @@ -1558,9 +1551,7 @@ class LLMHelper: """ logger.info(f"获取 {provider} 模型列表...") try: - from app.agent.llm.provider import LLMProviderManager - - models = await LLMProviderManager().list_models( + models = await resolve_llm_provider_runtime().list_models( provider_id=provider, api_key=api_key, base_url=base_url, @@ -1589,10 +1580,8 @@ class LLMHelper: base_url=base_url, ) try: - from app.agent.llm.provider import LLMProviderManager - model_list_base_url = ( - LLMProviderManager().resolve_model_list_base_url( + resolve_llm_provider_runtime().resolve_model_list_base_url( provider_id=provider, base_url=base_url, base_url_preset_id=base_url_preset, diff --git a/app/agent/llm/provider.py b/app/agent/llm/provider.py index d50da01a1..0661fe2a5 100644 --- a/app/agent/llm/provider.py +++ b/app/agent/llm/provider.py @@ -21,7 +21,7 @@ import httpx import jwt from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import LlmProviderAction, SystemConfigKey from app.foundation.singleton import Singleton diff --git a/app/agent/mcp.py b/app/agent/mcp.py index 8b274868c..99eae3060 100644 --- a/app/agent/mcp.py +++ b/app/agent/mcp.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Any, Optional from urllib.parse import urljoin -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.agent import ( AgentMcpServerConfig, diff --git a/app/agent/memory/__init__.py b/app/agent/memory/__init__.py index 313b3da41..c1698ffe9 100644 --- a/app/agent/memory/__init__.py +++ b/app/agent/memory/__init__.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict from app.runtime.config import settings -from app.db.oper.agentchat import AgentChatOper +from app.application.agentdata import AgentChatPort as AgentChatOper from app.runtime.log import logger from app.schemas.agent import ConversationMemory diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 0b1cc389c..13a43b80d 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -68,10 +68,15 @@ from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.chain.agent import AgentChain from app.runtime.config import settings from app.runtime.events import eventmanager -from app.runtime.extensions.plugin_manager import PluginManager -from app.db.oper.agentchat import AgentChatOper -from app.db.oper.agenttask import AgentTaskOper -from app.db.oper.user import UserOper +from app.application.plugin.runtime import get_plugin_manager + + +def _get_plugin_tools_revision() -> int: + """读取插件工具目录修订号,避免 Agent 编排依赖具体管理器类型。""" + return get_plugin_manager().get_plugin_agent_tools_revision() +from app.application.agentdata import AgentChatPort as AgentChatOper +from app.application.agentdata import AgentTaskPort as AgentTaskOper +from app.application.agentdata import UserPort as UserOper from app.runtime.log import logger from app.schemas.event import AgentLLMProviderEventData from app.schemas.event import AgentTokensUsageEventData @@ -1567,7 +1572,7 @@ class MoviePilotAgent: from app.agent.runtime_loader import get_tool_factory tool_factory = get_tool_factory() - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() for _attempt in range(tool_factory.CATALOG_BUILD_MAX_ATTEMPTS): before_revision = plugin_manager.get_plugin_agent_tools_revision() tools = self._initialize_tools() @@ -1663,7 +1668,7 @@ class MoviePilotAgent: if tool_catalog is not None and subagent_catalog is not None else ( self._tool_factory_revision(), - PluginManager().get_plugin_agent_tools_revision(), + _get_plugin_tools_revision(), ) ), ) @@ -1782,7 +1787,7 @@ class MoviePilotAgent: """ try: runtime_config = await self._resolve_llm_runtime_config() - plugin_revision = PluginManager().get_plugin_agent_tools_revision() + plugin_revision = _get_plugin_tools_revision() mcp_config_signature = agent_mcp_manager.config_signature() cached_bundle = self._compiled_agent_bundle catalog_is_fresh = bool( diff --git a/app/agent/policy/__init__.py b/app/agent/policy/__init__.py index a8e3149f4..a87cb9e9f 100644 --- a/app/agent/policy/__init__.py +++ b/app/agent/policy/__init__.py @@ -1,68 +1,55 @@ """MoviePilot Agent 宿主策略公共内部入口。""" -from app.agent.policy.contracts import ( - ActionEffect, - ActionPolicy, - AuthSource, - ConfirmationMode, - ExecutionOutcome, - ExecutionReceipt, - MigrationState, - PolicyDecision, - PolicyObservation, - PolicyPrincipal, - PrincipalRole, - PrincipalType, - RecoveryMode, - ResultSensitivity, - ToolInvocation, - ToolOrigin, - ToolPolicyContext, - ToolRevision, -) -from app.agent.policy.orchestrator import ( - DEFAULT_TOOL_POLICY_ORCHESTRATOR, - AgentToolPolicyOrchestrator, - call_policy_hook, -) -from app.agent.policy.registry import DEFAULT_TOOL_POLICY_REGISTRY, ToolPolicyRegistry -from app.agent.policy.sanitizer import ( - REDACTED_VALUE, - sanitize_for_host, - stable_type_name, - summarize_error, - summarize_input, - summarize_result, -) +from importlib import import_module +from typing import Any -__all__ = [ - "ActionEffect", - "ActionPolicy", - "AgentToolPolicyOrchestrator", - "AuthSource", - "ConfirmationMode", - "DEFAULT_TOOL_POLICY_ORCHESTRATOR", - "DEFAULT_TOOL_POLICY_REGISTRY", - "ExecutionOutcome", - "ExecutionReceipt", - "MigrationState", - "PolicyDecision", - "PolicyObservation", - "PolicyPrincipal", - "PrincipalRole", - "PrincipalType", - "REDACTED_VALUE", - "RecoveryMode", - "ResultSensitivity", - "ToolInvocation", - "ToolOrigin", - "ToolPolicyContext", - "ToolRevision", - "ToolPolicyRegistry", - "call_policy_hook", - "sanitize_for_host", - "stable_type_name", - "summarize_error", - "summarize_input", - "summarize_result", -] + +_EXPORT_MODULES = { + "ActionEffect": "app.agent.policy.contracts", + "ActionPolicy": "app.agent.policy.contracts", + "AuthSource": "app.agent.policy.contracts", + "ConfirmationMode": "app.agent.policy.contracts", + "ExecutionOutcome": "app.agent.policy.contracts", + "ExecutionReceipt": "app.agent.policy.contracts", + "MigrationState": "app.agent.policy.contracts", + "PolicyDecision": "app.agent.policy.contracts", + "PolicyObservation": "app.agent.policy.contracts", + "PolicyPrincipal": "app.agent.policy.contracts", + "PrincipalRole": "app.agent.policy.contracts", + "PrincipalType": "app.agent.policy.contracts", + "RecoveryMode": "app.agent.policy.contracts", + "ResultSensitivity": "app.agent.policy.contracts", + "ToolInvocation": "app.agent.policy.contracts", + "ToolOrigin": "app.agent.policy.contracts", + "ToolPolicyContext": "app.agent.policy.contracts", + "ToolRevision": "app.agent.policy.contracts", + "AgentToolPolicyOrchestrator": "app.agent.policy.orchestrator", + "DEFAULT_TOOL_POLICY_ORCHESTRATOR": "app.agent.policy.orchestrator", + "call_policy_hook": "app.agent.policy.orchestrator", + "DEFAULT_TOOL_POLICY_REGISTRY": "app.agent.policy.registry", + "ToolPolicyRegistry": "app.agent.policy.registry", + "REDACTED_VALUE": "app.agent.policy.sanitizer", + "sanitize_for_host": "app.agent.policy.sanitizer", + "stable_type_name": "app.agent.policy.sanitizer", + "summarize_error": "app.agent.policy.sanitizer", + "summarize_input": "app.agent.policy.sanitizer", + "summarize_result": "app.agent.policy.sanitizer", +} + + +def __getattr__(name: str) -> Any: + """首次访问公开策略对象时只加载其所属模块。""" + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module 'app.agent.policy' has no attribute {name!r}") + value = getattr(import_module(module_name), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """让惰性公开对象继续支持交互式发现。""" + return sorted(set(globals()) | set(_EXPORT_MODULES)) + + +__all__ = list(_EXPORT_MODULES) diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 56c1017c7..c44c5f10f 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -19,7 +19,7 @@ from app.agent.tools.tags import ToolTag from app.chain import ChainBase from app.runtime.config import settings from app.application.messaging.agent import matches_channel_admin -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger from app.schemas.message import Message from app.schemas.types import NotificationChannel, MessageType diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index c4c4015b5..62036edda 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -88,7 +88,7 @@ from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifie from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool from app.agent.llm.capability import AgentCapabilityManager -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager from app.runtime.log import logger from app.schemas.notification import ChannelCapabilityManager from app.schemas.types import NotificationChannel @@ -96,6 +96,18 @@ from .base import MoviePilotTool from .catalog import ToolCatalogError, ToolCatalogSnapshot +def _get_plugin_agent_tools() -> list[dict]: + """读取当前插件工具投影,隔离 Agent 工具工厂与 Runtime 管理器。""" + try: + return get_plugin_manager().get_plugin_agent_tools() + except RuntimeError as error: + # 纯工具目录探针可以在启动组合根之前运行;此时只跳过可选插件工具, + # 不隐式创建 PluginManager,避免冷导入重新引入 Runtime 定位器。 + if "尚未由启动组合根装配" not in str(error): + raise + return [] + + class MoviePilotToolFactory: """ MoviePilot工具工厂 @@ -288,7 +300,7 @@ class MoviePilotToolFactory: # 加载插件提供的工具 plugin_tools_count = 0 - plugin_tools_info = PluginManager().get_plugin_agent_tools() + plugin_tools_info = _get_plugin_agent_tools() for plugin_info in plugin_tools_info: plugin_id = plugin_info.get("plugin_id") plugin_name = plugin_info.get("plugin_name") @@ -337,7 +349,18 @@ class MoviePilotToolFactory: @classmethod def create_catalog(cls, **tool_kwargs) -> ToolCatalogSnapshot: """在插件目录稳定窗口内构造一份完整本地工具快照。""" - plugin_manager = PluginManager() + try: + plugin_manager = get_plugin_manager() + except RuntimeError as error: + # 没有启动上下文时仍允许构造内置工具目录;插件工具会在正式启动 + # 后由组合根提供的 Runtime 中重新物化。 + if "尚未由启动组合根装配" not in str(error): + raise + return ToolCatalogSnapshot.from_tools( + cls.create_tools(**tool_kwargs), + plugin_revision=0, + factory_revision=cls.catalog_factory_revision(), + ) for _attempt in range(cls.CATALOG_BUILD_MAX_ATTEMPTS): before_revision = plugin_manager.get_plugin_agent_tools_revision() tools = cls.create_tools(**tool_kwargs) diff --git a/app/agent/tools/impl/_filter_rule_utils.py b/app/agent/tools/impl/_filter_rule_utils.py index 774de5d27..d5cc8b60d 100644 --- a/app/agent/tools/impl/_filter_rule_utils.py +++ b/app/agent/tools/impl/_filter_rule_utils.py @@ -5,8 +5,8 @@ import re from typing import Any, Dict, Iterable, Optional from app.runtime.events import eventmanager -from app.db.oper.subscribe import SubscribeOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.agentdata import SubscribePort as SubscribeOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.application.rules import RuleHelper from app.application.rules import RuleParser from app.application.rules import BUILTIN_RULE_SET diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 165c05c3d..6a92233f2 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -5,9 +5,9 @@ import shutil from typing import Any, Optional from app.runtime.config import settings -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager from app.application.plugin.install import PluginInstallCommand -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.market import PluginHelper from app.adapters.system.plugin.package import PluginPackageManager @@ -26,7 +26,7 @@ def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]: """ 获取已安装插件的基础信息快照。 """ - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() for plugin in plugin_manager.get_local_plugins(): if plugin.id == plugin_id: return { @@ -81,7 +81,7 @@ def refresh_plugin_registrations(plugin_id: str) -> None: def reload_plugin_runtime(plugin_id: str) -> None: """重载插件实例并重新注册其命令、定时任务和 API。""" - PluginManager().reload_plugin(plugin_id) + get_plugin_manager().reload_plugin(plugin_id) refresh_plugin_registrations(plugin_id) @@ -157,7 +157,7 @@ async def enrich_installed_plugin_sources( if not missing_source_plugins: return installed_plugins - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() local_repo_map = _map_plugins_by_id(plugin_manager.get_local_repo_plugins()) for plugin in missing_source_plugins: source_plugin = local_repo_map.get(getattr(plugin, "id", None)) @@ -184,7 +184,7 @@ async def load_market_plugins(force_refresh: bool = False) -> list[Any]: """ 聚合插件市场与本地插件仓库中的候选插件。 """ - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() online_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh) local_repo_plugins = plugin_manager.get_local_repo_plugins() if not online_plugins and not local_repo_plugins: @@ -196,7 +196,7 @@ def list_installed_plugins() -> list[Any]: """ 返回当前已安装插件列表。 """ - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() return [plugin for plugin in plugin_manager.get_local_plugins() if plugin.installed] @@ -300,7 +300,7 @@ async def install_plugin_runtime( """ 按现有插件接口的行为安装插件,并刷新运行态注册信息。 """ - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() plugin_helper = PluginHelper() package_manager = PluginPackageManager(plugin_helper) @@ -395,7 +395,7 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: remove_plugin_api(plugin_id) remove_plugin_job(plugin_id) - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() plugin_class = plugin_manager.plugins.get(plugin_id) was_clone = bool(getattr(plugin_class, "is_clone", False)) clone_files_removed = False diff --git a/app/agent/tools/impl/add_download_tasks.py b/app/agent/tools/impl/add_download_tasks.py index f38ead4a4..7b8e34f1c 100644 --- a/app/agent/tools/impl/add_download_tasks.py +++ b/app/agent/tools/impl/add_download_tasks.py @@ -15,7 +15,7 @@ from app.chain.search import SearchChain from app.runtime.config import settings from app.domain.context import Context from app.domain.metainfo import MetaInfo -from app.db.oper.site import SiteOper +from app.application.agentdata import SitePort as SiteOper from app.application.directory import DirectoryHelper, validate_download_save_path from app.runtime.log import logger from app.schemas.file import FileURI diff --git a/app/agent/tools/impl/add_subscribe.py b/app/agent/tools/impl/add_subscribe.py index 23e026f5d..987a61fa9 100644 --- a/app/agent/tools/impl/add_subscribe.py +++ b/app/agent/tools/impl/add_subscribe.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.subscribe import SubscribeChain -from app.db.oper.user import UserOper +from app.application.agentdata import UserPort as UserOper from app.runtime.log import logger from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel from app.domain.media import normalize_music_type diff --git a/app/agent/tools/impl/create_agent_task.py b/app/agent/tools/impl/create_agent_task.py index 686a336f2..ebce7d3e1 100644 --- a/app/agent/tools/impl/create_agent_task.py +++ b/app/agent/tools/impl/create_agent_task.py @@ -8,8 +8,8 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.config import settings -from app.db.oper.agentchat import AgentChatOper -from app.db.oper.agenttask import AgentTaskOper +from app.application.agentdata import AgentChatPort as AgentChatOper +from app.application.agentdata import AgentTaskPort as AgentTaskOper from app.runtime.scheduling import TimerUtils diff --git a/app/agent/tools/impl/delete_agent_task.py b/app/agent/tools/impl/delete_agent_task.py index 0f12a54ed..aa8ef94ae 100644 --- a/app/agent/tools/impl/delete_agent_task.py +++ b/app/agent/tools/impl/delete_agent_task.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.agenttask import AgentTaskOper +from app.application.agentdata import AgentTaskPort as AgentTaskOper class DeleteAgentTaskInput(BaseModel): diff --git a/app/agent/tools/impl/delete_download_history.py b/app/agent/tools/impl/delete_download_history.py index 0b72a0a4e..adc88ea98 100644 --- a/app/agent/tools/impl/delete_download_history.py +++ b/app/agent/tools/impl/delete_download_history.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.downloadhistory import DownloadHistoryOper +from app.application.agentdata import DownloadHistoryPort as DownloadHistoryOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/delete_subscribe.py b/app/agent/tools/impl/delete_subscribe.py index e2a7bb055..ca00ae5c3 100644 --- a/app/agent/tools/impl/delete_subscribe.py +++ b/app/agent/tools/impl/delete_subscribe.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.events import eventmanager -from app.db.oper.subscribe import SubscribeOper +from app.application.agentdata import SubscribePort as SubscribeOper from app.adapters.external.server import MoviePilotServerHelper from app.runtime.log import logger from app.schemas.types import EventType diff --git a/app/agent/tools/impl/delete_transfer_history.py b/app/agent/tools/impl/delete_transfer_history.py index e66c5b882..e001dde3c 100644 --- a/app/agent/tools/impl/delete_transfer_history.py +++ b/app/agent/tools/impl/delete_transfer_history.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.storage import StorageChain -from app.db.oper.transferhistory import TransferHistoryOper +from app.application.agentdata import TransferHistoryPort as TransferHistoryOper from app.runtime.log import logger from app.schemas.workflow import FileItem diff --git a/app/agent/tools/impl/query_agent_tasks.py b/app/agent/tools/impl/query_agent_tasks.py index e43780bb6..f159c1c43 100644 --- a/app/agent/tools/impl/query_agent_tasks.py +++ b/app/agent/tools/impl/query_agent_tasks.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.config import settings -from app.db.oper.agenttask import AgentTaskOper +from app.application.agentdata import AgentTaskPort as AgentTaskOper class QueryAgentTasksInput(BaseModel): diff --git a/app/agent/tools/impl/query_custom_identifiers.py b/app/agent/tools/impl/query_custom_identifiers.py index db2ecaca1..078cba0bd 100644 --- a/app/agent/tools/impl/query_custom_identifiers.py +++ b/app/agent/tools/impl/query_custom_identifiers.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import SystemConfigKey diff --git a/app/agent/tools/impl/query_download_tasks.py b/app/agent/tools/impl/query_download_tasks.py index 1cae65a32..e396ad1a4 100644 --- a/app/agent/tools/impl/query_download_tasks.py +++ b/app/agent/tools/impl/query_download_tasks.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.download import DownloadChain -from app.db.oper.downloadhistory import DownloadHistoryOper +from app.application.agentdata import DownloadHistoryPort as DownloadHistoryOper from app.runtime.log import logger from app.schemas.transfer import DownloaderTorrent from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent diff --git a/app/agent/tools/impl/query_downloaders.py b/app/agent/tools/impl/query_downloaders.py index 881555c68..590eefde1 100644 --- a/app/agent/tools/impl/query_downloaders.py +++ b/app/agent/tools/impl/query_downloaders.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import SystemConfigKey diff --git a/app/agent/tools/impl/query_library_latest.py b/app/agent/tools/impl/query_library_latest.py index 74be40716..93242c50a 100644 --- a/app/agent/tools/impl/query_library_latest.py +++ b/app/agent/tools/impl/query_library_latest.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.mediaserver import MediaServerChain -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger PAGE_SIZE = 20 diff --git a/app/agent/tools/impl/query_plugin_capabilities.py b/app/agent/tools/impl/query_plugin_capabilities.py index 4a1ff16ee..dd0a3c50b 100644 --- a/app/agent/tools/impl/query_plugin_capabilities.py +++ b/app/agent/tools/impl/query_plugin_capabilities.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager from app.runtime.log import logger @@ -48,7 +48,7 @@ class QueryPluginCapabilitiesTool(MoviePilotTool): @staticmethod def _load_plugin_capabilities(plugin_id: Optional[str] = None) -> dict: """读取运行中插件实例暴露的内存能力信息。""" - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() result = {} commands = plugin_manager.get_plugin_commands(pid=plugin_id) diff --git a/app/agent/tools/impl/query_plugin_config.py b/app/agent/tools/impl/query_plugin_config.py index f6e3bd500..235f302b1 100644 --- a/app/agent/tools/impl/query_plugin_config.py +++ b/app/agent/tools/impl/query_plugin_config.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager from app.runtime.log import logger @@ -56,7 +56,7 @@ class QueryPluginConfigTool(MoviePilotTool): ensure_ascii=False, ) - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() saved_config = plugin_manager.get_plugin_config(plugin_id) or {} result = { "success": True, diff --git a/app/agent/tools/impl/query_plugin_data.py b/app/agent/tools/impl/query_plugin_data.py index fff735e37..2eee84a62 100644 --- a/app/agent/tools/impl/query_plugin_data.py +++ b/app/agent/tools/impl/query_plugin_data.py @@ -12,7 +12,7 @@ from app.agent.tools.impl._plugin_tool_utils import ( build_preview_payload, get_plugin_snapshot, ) -from app.db.oper.plugindata import PluginDataOper +from app.application.agentdata import PluginDataPort as PluginDataOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/query_site_userdata.py b/app/agent/tools/impl/query_site_userdata.py index 05926765a..9c295bb1b 100644 --- a/app/agent/tools/impl/query_site_userdata.py +++ b/app/agent/tools/impl/query_site_userdata.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.site import SiteOper +from app.application.agentdata import SitePort as SiteOper from app.runtime.log import logger SITE_USERDATA_DETAIL_PREVIEW_LIMIT = 10 diff --git a/app/agent/tools/impl/query_sites.py b/app/agent/tools/impl/query_sites.py index d7cf43a5b..f45d8a24a 100644 --- a/app/agent/tools/impl/query_sites.py +++ b/app/agent/tools/impl/query_sites.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.site import SiteOper +from app.application.agentdata import SitePort as SiteOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/query_subscribe_history.py b/app/agent/tools/impl/query_subscribe_history.py index d340ba0f9..e4ab197d2 100644 --- a/app/agent/tools/impl/query_subscribe_history.py +++ b/app/agent/tools/impl/query_subscribe_history.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.subscribehistory import SubscribeHistoryOper +from app.application.agentdata import SubscribeHistoryPort as SubscribeHistoryOper from app.runtime.log import logger from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent from app.domain.media import normalize_music_type diff --git a/app/agent/tools/impl/query_subscribes.py b/app/agent/tools/impl/query_subscribes.py index 2fcb24971..7ccfd7aa5 100644 --- a/app/agent/tools/impl/query_subscribes.py +++ b/app/agent/tools/impl/query_subscribes.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.subscribe import SubscribeOper +from app.application.agentdata import SubscribePort as SubscribeOper from app.runtime.log import logger from app.schemas.subscribe import Subscribe as SubscribeSchema from app.schemas.types import ( diff --git a/app/agent/tools/impl/query_system_settings.py b/app/agent/tools/impl/query_system_settings.py index e86c29bd8..aca39d894 100644 --- a/app/agent/tools/impl/query_system_settings.py +++ b/app/agent/tools/impl/query_system_settings.py @@ -16,7 +16,7 @@ from app.agent.tools.impl._system_setting_utils import ( should_redact_setting, ) from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/query_transfer_history.py b/app/agent/tools/impl/query_transfer_history.py index a84343a53..1afa8932e 100644 --- a/app/agent/tools/impl/query_transfer_history.py +++ b/app/agent/tools/impl/query_transfer_history.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.transferhistory import TransferHistoryOper +from app.application.agentdata import TransferHistoryPort as TransferHistoryOper from app.runtime.log import logger from app.schemas.types import media_type_to_agent from app.foundation.text import cut as jieba_cut diff --git a/app/agent/tools/impl/query_workflows.py b/app/agent/tools/impl/query_workflows.py index 8dc6fa118..93d0e9b5a 100644 --- a/app/agent/tools/impl/query_workflows.py +++ b/app/agent/tools/impl/query_workflows.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.workflow import WorkflowOper +from app.application.agentdata import WorkflowPort as WorkflowOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/run_agent_task.py b/app/agent/tools/impl/run_agent_task.py index 10ebc4966..60add013c 100644 --- a/app/agent/tools/impl/run_agent_task.py +++ b/app/agent/tools/impl/run_agent_task.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.db.oper.agenttask import AgentTaskOper +from app.application.agentdata import AgentTaskPort as AgentTaskOper class RunAgentTaskInput(BaseModel): diff --git a/app/agent/tools/impl/run_workflow.py b/app/agent/tools/impl/run_workflow.py index 694d2a452..8351fb7be 100644 --- a/app/agent/tools/impl/run_workflow.py +++ b/app/agent/tools/impl/run_workflow.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.workflow import WorkflowChain -from app.db.oper.workflow import WorkflowOper +from app.application.workflow import get_configured_workflow_query from app.runtime.log import logger @@ -62,7 +62,7 @@ class RunWorkflowTool(MoviePilotTool): ) try: - workflow = await WorkflowOper().async_get(workflow_id) + workflow = await get_configured_workflow_query().get(workflow_id) if not workflow: return f"未找到工作流:{workflow_id},请使用 query_workflows 工具查询可用的工作流" diff --git a/app/agent/tools/impl/search_subscribe.py b/app/agent/tools/impl/search_subscribe.py index fdc08d497..435331551 100644 --- a/app/agent/tools/impl/search_subscribe.py +++ b/app/agent/tools/impl/search_subscribe.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.subscribe import SubscribeChain -from app.db.oper.subscribe import SubscribeOper +from app.application.agentdata import SubscribePort as SubscribeOper from app.runtime.log import logger from app.schemas.types import media_type_to_agent diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index 767be5361..3db922344 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.search import SearchChain -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger from app.schemas.types import MediaSource, MediaType, SystemConfigKey diff --git a/app/agent/tools/impl/test_site.py b/app/agent/tools/impl/test_site.py index 12707bf07..5a38d247b 100644 --- a/app/agent/tools/impl/test_site.py +++ b/app/agent/tools/impl/test_site.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.site import SiteChain -from app.db.oper.site import SiteOper +from app.application.agentdata import SitePort as SiteOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/update_agent_task.py b/app/agent/tools/impl/update_agent_task.py index a9c2689bc..92898ab53 100644 --- a/app/agent/tools/impl/update_agent_task.py +++ b/app/agent/tools/impl/update_agent_task.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, model_validator from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.config import settings -from app.db.oper.agenttask import AgentTaskOper +from app.application.agentdata import AgentTaskPort as AgentTaskOper from app.runtime.scheduling import TimerUtils diff --git a/app/agent/tools/impl/update_custom_identifiers.py b/app/agent/tools/impl/update_custom_identifiers.py index 52dc898fe..f0481611c 100644 --- a/app/agent/tools/impl/update_custom_identifiers.py +++ b/app/agent/tools/impl/update_custom_identifiers.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.domain.metainfo import clear_rust_parse_options_cache -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import SystemConfigKey diff --git a/app/agent/tools/impl/update_plugin_config.py b/app/agent/tools/impl/update_plugin_config.py index ac1ed35f7..9585150b4 100644 --- a/app/agent/tools/impl/update_plugin_config.py +++ b/app/agent/tools/impl/update_plugin_config.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.agent.tools.impl._plugin_tool_utils import get_plugin_snapshot -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager from app.runtime.log import logger @@ -88,7 +88,7 @@ class UpdatePluginConfigTool(MoviePilotTool): ensure_ascii=False, ) - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() current_config = dict(plugin_manager.get_plugin_config(plugin_id) or {}) # merge 模式以当前保存值为基准,replace 模式则从空配置开始重建。 diff --git a/app/agent/tools/impl/update_site.py b/app/agent/tools/impl/update_site.py index 6a1ad9ed9..62e68f040 100644 --- a/app/agent/tools/impl/update_site.py +++ b/app/agent/tools/impl/update_site.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.events import eventmanager -from app.db.oper.site import SiteOper +from app.application.agentdata import SitePort as SiteOper from app.runtime.log import logger from app.schemas.types import EventType from app.foundation import url as url_tools diff --git a/app/agent/tools/impl/update_site_cookie.py b/app/agent/tools/impl/update_site_cookie.py index 3472234a2..af31db387 100644 --- a/app/agent/tools/impl/update_site_cookie.py +++ b/app/agent/tools/impl/update_site_cookie.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.site import SiteChain -from app.db.oper.site import SiteOper +from app.application.agentdata import SitePort as SiteOper from app.runtime.log import logger diff --git a/app/agent/tools/impl/update_subscribe.py b/app/agent/tools/impl/update_subscribe.py index 2cf7459fc..9240b6adf 100644 --- a/app/agent/tools/impl/update_subscribe.py +++ b/app/agent/tools/impl/update_subscribe.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.runtime.events import eventmanager -from app.db.oper.subscribe import SubscribeOper +from app.application.agentdata import SubscribePort as SubscribeOper from app.runtime.log import logger from app.schemas.event import SubscribeModifiedEventData from app.schemas.types import EventType, media_type_to_agent diff --git a/app/agent/tools/impl/update_system_settings.py b/app/agent/tools/impl/update_system_settings.py index 56718f52d..7b7e3e062 100644 --- a/app/agent/tools/impl/update_system_settings.py +++ b/app/agent/tools/impl/update_system_settings.py @@ -18,7 +18,7 @@ from app.agent.tools.impl._system_setting_utils import ( ) from app.runtime.config import settings from app.runtime.events import eventmanager -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.event import ConfigChangeEventData from app.schemas.types import EventType diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py index 8be289458..584a1dbed 100644 --- a/app/agent/tools/manager.py +++ b/app/agent/tools/manager.py @@ -118,9 +118,9 @@ class MoviePilotToolsManager: self._load_tools_locked() return - from app.runtime.extensions.plugin_manager import PluginManager + from app.application.plugin.runtime import get_plugin_manager - plugin_manager = PluginManager() + plugin_manager = get_plugin_manager() if ( self._plugin_agent_tools_revision == plugin_manager.get_plugin_agent_tools_revision() diff --git a/app/api/data.py b/app/api/data.py new file mode 100644 index 000000000..b6a879883 --- /dev/null +++ b/app/api/data.py @@ -0,0 +1,85 @@ +"""API 请求数据端口注册表。""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable, Generator +from typing import Any + + +SessionProvider = Callable[[], Generator[Any, None, None]] +AsyncSessionProvider = Callable[[], AsyncGenerator[Any, None]] +RepositoryFactory = Callable[[Any], Any] +StandaloneFactory = Callable[[], Any] +UnitOfWorkFactory = Callable[[Any], Any] + + +class ApiDataPorts: + """保存 API 依赖所需的会话、仓储和事务端口。""" + + def __init__( + self, + *, + sync_session: SessionProvider, + async_session: AsyncSessionProvider, + repositories: dict[str, RepositoryFactory], + standalone: dict[str, StandaloneFactory], + unit_of_work: dict[str, UnitOfWorkFactory], + ) -> None: + """保存由启动组合根提供的具体实现工厂。""" + self.sync_session = sync_session + self.async_session = async_session + self.repositories = repositories + self.standalone = standalone + self.unit_of_work = unit_of_work + + def repository(self, name: str, session: Any) -> Any: + """按能力名构造请求级仓储。""" + return self.repositories[name](session) + + def standalone_repository(self, name: str) -> Any: + """构造不绑定请求会话的持久化端口。""" + return self.standalone[name]() + + def transaction(self, name: str, session: Any) -> Any: + """构造请求级事务端口。""" + return self.unit_of_work[name](session) + + +_ports: ApiDataPorts | None = None + + +def configure_api_data_ports( + *, + sync_session: SessionProvider, + async_session: AsyncSessionProvider, + repositories: dict[str, RepositoryFactory], + standalone: dict[str, StandaloneFactory], + unit_of_work: dict[str, UnitOfWorkFactory], +) -> None: + """由启动组合根登记 API 数据实现,切断 API 对数据库实现包的直接导入。""" + global _ports + _ports = ApiDataPorts( + sync_session=sync_session, + async_session=async_session, + repositories=repositories, + standalone=standalone, + unit_of_work=unit_of_work, + ) + + +def get_api_data_ports() -> ApiDataPorts: + """返回当前 API 数据端口集合。""" + if _ports is None: + raise RuntimeError("API 数据端口尚未由启动组合根配置") + return _ports + + +def get_db() -> Generator[Any, None, None]: + """向 FastAPI 暴露同步请求会话依赖。""" + yield from get_api_data_ports().sync_session() + + +async def get_async_db() -> AsyncGenerator[Any, None]: + """向 FastAPI 暴露异步请求会话依赖。""" + async for session in get_api_data_ports().async_session(): + yield session diff --git a/app/api/deps.py b/app/api/deps.py index cd43387a8..c3f120154 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -6,6 +6,8 @@ HTTPException 表达。它们此前住在 app/db/oper/user.py 里,与数据访 鉴权是 HTTP 层的关注点,产出的是 403/400 而不是数据。放在 db 包里既让数据层反向 依赖了 fastapi,也使这部分逻辑无法与数据访问分开度量。 """ +from typing import Any + from fastapi import BackgroundTasks, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -16,13 +18,24 @@ from app.application.subscription.identity import ( DeleteSubscriptionsByIdentityCommand, ) from app.application.subscription.search import SearchSubscriptionsCommand +from app.application.subscription.query import SubscriptionQueryService +from app.application.subscription.mutation import SubscriptionMutationService from app.application.site.mutation import SiteMutationCommand +from app.application.site.query import SiteQueryService from app.application.workflow import ( WorkflowDefinitionCommand, WorkflowMutationCommand, + WorkflowQueryService, ) +from app.application.messaging.message import MessageQueryService +from app.application.messaging.chat import AgentChatService +from app.application.mediaserver import MediaServerQueryService +from app.application.servarr import ServarrSubscriptionService +from app.application.dashboard import DashboardQueryService from app.application.history import ( DownloadHistoryMutationCommand, + HistoryQueryService, + TransferHistoryLookupService, TransferHistoryMutationCommand, clear_transfer_failures, ) @@ -30,32 +43,42 @@ from app.application.plugin.config import PluginConfigCommand from app.application.commands import init_commands from app.application.plugins import register_plugin_api from app.application.scheduling import update_plugin_job -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token +from app.application.security.user import UserService +from app.application.security.auth import AuthService +from app.application.security.passkeys import PasskeyService from app.adapters.external.server import MoviePilotServerHelper -from app.db import get_async_db, get_db -from app.db.models.user import User -from app.db.oper.subscribe import SubscribeOper -from app.db.oper.site import SiteOper -from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork +from app.api.data import get_api_data_ports, get_async_db, get_db from app.runtime.events import eventmanager -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager as PluginManager from app.runtime.log import logger from app.schemas.event import PluginDataResetEventData from app.schemas.types import ChainEventType, EventType -from app.scheduler import Scheduler +from app.application.scheduling import Scheduler from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.domain import site as site_rules from app.foundation import url as url_tools -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.workflow import WorkflowOper -from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.oper.transferhistory import TransferHistoryOper from app.runtime.config import global_vars from app.workflow import WorkFlowManager from app.chain.storage import StorageChain from app.schemas.workflow import FileItem as _SchemaFileItem +def _repository(name: str, session: Any) -> Any: + """构造绑定当前请求会话的数据仓储。""" + return get_api_data_ports().repository(name, session) + + +def _standalone_repository(name: str) -> Any: + """构造无需绑定请求会话的数据端口。""" + return get_api_data_ports().standalone_repository(name) + + +def _transaction(name: str, session: Any) -> Any: + """构造绑定当前请求会话的事务端口。""" + return get_api_data_ports().transaction(name, session) + + async def _publish_subscribe_deleted( subscribe_id: int, subscribe_info: dict, @@ -72,8 +95,8 @@ def get_delete_subscribe_command( ) -> DeleteSubscribeCommand: """组装请求级订阅删除用例及其具体适配器。""" return DeleteSubscribeCommand( - repository=SubscribeOper(db), - unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + repository=_repository("subscribe", db), + unit_of_work=_transaction("async", db), publish_deleted=_publish_subscribe_deleted, report_deleted=MoviePilotServerHelper.sub_done_async, ) @@ -95,8 +118,8 @@ def get_delete_subscriptions_by_identity_command( ) -> DeleteSubscriptionsByIdentityCommand: """组装请求级按媒体身份删除订阅用例。""" return DeleteSubscriptionsByIdentityCommand( - repository=SubscribeOper(db), - unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + repository=_repository("subscribe", db), + unit_of_work=_transaction("async", db), publish_deleted=_publish_subscribe_deleted, handle_event_error=_log_subscribe_deleted_event_error, ) @@ -118,11 +141,71 @@ def get_search_subscriptions_command( ) return SearchSubscriptionsCommand( - repository=SubscribeOper(db), + repository=_repository("subscribe", db), schedule_search=schedule_search, ) +def get_subscription_query_service( + db: AsyncSession = Depends(get_async_db), +) -> SubscriptionQueryService: + """组装订阅和订阅历史异步查询服务。""" + return SubscriptionQueryService( + repository=_repository("subscribe", db), + async_repository=_repository("subscribe", db), + history_repository=_repository("subscribe_history", db), + ) + + +def get_user_service( + db: AsyncSession = Depends(get_async_db), +) -> UserService: + """组装用户管理应用服务。""" + return UserService(repository=_repository("user", db)) + + +def get_auth_service() -> AuthService: + """组装同步认证应用服务。""" + return AuthService( + users=_standalone_repository("user"), + config=_standalone_repository("system_config"), + passkeys=_standalone_repository("passkey"), + ) + + +def get_passkey_service() -> PasskeyService: + """组装 PassKey 应用服务。""" + return PasskeyService(repository=_standalone_repository("passkey")) + + +def get_subscription_mutation_service( + db: AsyncSession = Depends(get_async_db), +) -> SubscriptionMutationService: + """组装异步订阅写服务。""" + return SubscriptionMutationService( + repository=_repository("subscribe", db), + history_repository=_repository("subscribe_history", db), + ) + + +def get_subscription_sync_mutation_service( + db: Session = Depends(get_db), +) -> SubscriptionMutationService: + """组装同步订阅查询服务,供文件信息接口使用。""" + return SubscriptionMutationService(repository=_repository("subscribe", db)) + + +def get_servarr_subscription_service( + async_db: AsyncSession = Depends(get_async_db), + db: Session = Depends(get_db), +) -> ServarrSubscriptionService: + """组装 Servarr 兼容路由的请求级订阅数据用例。""" + return ServarrSubscriptionService( + async_repository=_repository("subscribe", async_db), + sync_repository=_repository("subscribe", db), + ) + + async def _publish_site_updated(payload: dict) -> None: """发布已提交的站点更新事件。""" await eventmanager.async_send_event(EventType.SiteUpdated, payload) @@ -145,8 +228,8 @@ def get_site_mutation_command( return f"{scheme}://{netloc}/" return SiteMutationCommand( - repository=SiteOper(db), - unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + repository=_repository("site", db), + unit_of_work=_transaction("async", db), auth_level_provider=lambda: sites_helper.auth_level, indexer_loader=sites_helper.async_get_indexer, domain_extractor=site_rules.extract_domain, @@ -156,6 +239,20 @@ def get_site_mutation_command( ) +def get_site_query_service( + db: AsyncSession = Depends(get_async_db), +) -> SiteQueryService: + """组装站点异步查询服务。""" + return SiteQueryService(repository=_repository("site", db)) + + +def get_site_sync_query_service( + db: Session = Depends(get_db), +) -> SiteQueryService: + """组装站点同步查询服务,用于同步 Chain 路由。""" + return SiteQueryService(repository=_repository("site", db)) + + def get_workflow_mutation_command( db: Session = Depends(get_db), ) -> WorkflowMutationCommand: @@ -163,15 +260,15 @@ def get_workflow_mutation_command( scheduler = Scheduler() workflow_manager = WorkFlowManager() return WorkflowMutationCommand( - repository=WorkflowOper(db), - unit_of_work=SqlAlchemyUnitOfWork(db), + repository=_repository("workflow", db), + unit_of_work=_transaction("sync", db), add_timer=scheduler.update_workflow_job, remove_timer=scheduler.remove_workflow_job, load_event=workflow_manager.load_workflow_events, remove_event=workflow_manager.remove_workflow_event, refresh_event=workflow_manager.update_workflow_event, stop_running=global_vars.stop_workflow, - delete_cache=lambda workflow_id: SystemConfigOper().delete( + delete_cache=lambda workflow_id: _standalone_repository("system_config").delete( f"WorkflowCache-{workflow_id}" ), ) @@ -182,35 +279,92 @@ def get_workflow_definition_command( ) -> WorkflowDefinitionCommand: """组装工作流创建、复用和重置的异步写用例。""" return WorkflowDefinitionCommand( - repository=WorkflowOper(db), - unit_of_work=SqlAlchemyAsyncUnitOfWork(db), + repository=_repository("workflow", db), + unit_of_work=_transaction("async", db), stop_running=global_vars.stop_workflow, - delete_cache=lambda workflow_id: SystemConfigOper().delete( + delete_cache=lambda workflow_id: _standalone_repository("system_config").delete( f"WorkflowCache-{workflow_id}" ), report_fork=MoviePilotServerHelper.async_workflow_fork_by_id, ) +def get_workflow_query_service( + db: AsyncSession = Depends(get_async_db), +) -> WorkflowQueryService: + """组装工作流只读查询用例,避免端点直接持有数据库操作器。""" + return WorkflowQueryService(repository=_repository("workflow", db)) + + +def get_message_query_service( + db: AsyncSession = Depends(get_async_db), +) -> MessageQueryService: + """组装消息历史异步查询服务。""" + return MessageQueryService(repository=_repository("message", db)) + + +def get_agent_chat_service( + db: AsyncSession = Depends(get_async_db), +) -> AgentChatService: + """组装 Agent 会话历史查询和删除服务。""" + return AgentChatService(repository=_repository("agent_chat", db)) + + +def get_mediaserver_query_service( + db: AsyncSession = Depends(get_async_db), +) -> MediaServerQueryService: + """组装媒体服务器本地条目异步查询服务。""" + return MediaServerQueryService(repository=_repository("media_server", db)) + + +def get_dashboard_query_service( + db: Session = Depends(get_db), +) -> DashboardQueryService: + """组装 Dashboard 媒体与整理历史统计查询服务。""" + from app.chain.dashboard import DashboardChain + + return DashboardQueryService( + repository=_repository("transfer_history", db), + media_statistics=DashboardChain().media_statistic, + ) + + def get_download_history_mutation_command( db: Session = Depends(get_db), ) -> DownloadHistoryMutationCommand: """组装下载历史删除用例及其请求级事务。""" return DownloadHistoryMutationCommand( - repository=DownloadHistoryOper(db), - unit_of_work=SqlAlchemyUnitOfWork(db), + repository=_repository("download_history", db), + unit_of_work=_transaction("sync", db), ) +def get_history_query_service( + db: AsyncSession = Depends(get_async_db), +) -> HistoryQueryService: + """组装历史列表和详情异步查询服务。""" + return HistoryQueryService( + download_repository=_repository("download_history", db), + transfer_repository=_repository("transfer_history", db), + ) + + +def get_transfer_history_lookup_service( + db: Session = Depends(get_db), +) -> TransferHistoryLookupService: + """组装手动整理使用的同步历史投影服务。""" + return TransferHistoryLookupService(_repository("transfer_history", db)) + + def get_transfer_history_mutation_command( db: Session = Depends(get_db), ) -> TransferHistoryMutationCommand: """组装整理历史删除、文件处理和事件发布用例。""" storage_chain = StorageChain() return TransferHistoryMutationCommand( - repository=TransferHistoryOper(db), - download_repository=DownloadHistoryOper(db), - unit_of_work=SqlAlchemyUnitOfWork(db), + repository=_repository("transfer_history", db), + download_repository=_repository("download_history", db), + unit_of_work=_transaction("sync", db), file_item_factory=lambda payload: _SchemaFileItem(**payload), delete_media_file=storage_chain.delete_media_file, publish_download_file_deleted=lambda payload: eventmanager.send_event( @@ -257,11 +411,11 @@ def get_plugin_config_command() -> PluginConfigCommand: def get_current_user( db: Session = Depends(get_db), token_data: _SchemaTokenPayload = Depends(verify_token) -) -> User: +) -> Any: """ 获取当前用户 """ - user = User.get(db, rid=token_data.sub) + user = _repository("user", db).get_by_id(token_data.sub) if not user: raise HTTPException(status_code=403, detail="用户不存在") return user @@ -270,19 +424,19 @@ def get_current_user( async def get_current_user_async( db: AsyncSession = Depends(get_async_db), token_data: _SchemaTokenPayload = Depends(verify_token) -) -> User: +) -> Any: """ 异步获取当前用户 """ - user = await User.async_get(db, rid=token_data.sub) + user = await _repository("user", db).async_get_by_id(token_data.sub) if not user: raise HTTPException(status_code=403, detail="用户不存在") return user def get_current_active_user( - current_user: User = Depends(get_current_user), -) -> User: + current_user: Any = Depends(get_current_user), +) -> Any: """ 获取当前激活用户 """ @@ -292,8 +446,8 @@ def get_current_active_user( async def get_current_active_user_async( - current_user: User = Depends(get_current_user_async), -) -> User: + current_user: Any = Depends(get_current_user_async), +) -> Any: """ 异步获取当前激活用户 """ @@ -302,7 +456,7 @@ async def get_current_active_user_async( return current_user -def _ensure_manage_user(current_user: User) -> User: +def _ensure_manage_user(current_user: Any) -> Any: """ 校验用户具备全局管理权限。 """ @@ -315,8 +469,8 @@ def _ensure_manage_user(current_user: User) -> User: def get_current_active_manage_user( - current_user: User = Depends(get_current_active_user), -) -> User: + current_user: Any = Depends(get_current_active_user), +) -> Any: """ 获取当前拥有管理权限的激活用户。 """ @@ -324,8 +478,8 @@ def get_current_active_manage_user( async def get_current_active_manage_user_async( - current_user: User = Depends(get_current_active_user_async), -) -> User: + current_user: Any = Depends(get_current_active_user_async), +) -> Any: """ 异步获取当前拥有管理权限的激活用户。 """ @@ -333,8 +487,8 @@ async def get_current_active_manage_user_async( def get_current_active_superuser( - current_user: User = Depends(get_current_user), -) -> User: + current_user: Any = Depends(get_current_user), +) -> Any: """ 获取当前激活超级管理员 """ @@ -346,8 +500,8 @@ def get_current_active_superuser( async def get_current_active_superuser_async( - current_user: User = Depends(get_current_user_async), -) -> User: + current_user: Any = Depends(get_current_user_async), +) -> Any: """ 异步获取当前激活超级管理员 """ diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index 782fcf22f..38dbc4842 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -16,7 +16,6 @@ from typing import Any, AsyncIterator, Callable, Optional, Union from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, StreamingResponse -from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.agent import AgentChatDisplaySaveRequest as _SchemaAgentChatDisplaySaveRequest from app.schemas.agent import AgentChatSessionDetail as _SchemaAgentChatSessionDetail @@ -45,12 +44,14 @@ from app.chain.message import MessageChain from app.command import Command from app.runtime.config import global_vars, settings from app.runtime.events import Event, EventManager -from app.db import get_async_db -from app.db.oper.agentchat import AgentChatOper -from app.db.models import User -from app.db.models.agentchat import AgentChat -from app.db.oper.user import UserOper -from app.api.deps import get_current_active_user +from app.api.principal import ApiPrincipal +from app.api.deps import get_agent_chat_service, get_current_active_user +from app.application.messaging.chat import ( + AgentChatRecord, + AgentChatService, + get_configured_agent_chat_service, +) +from app.application.security.user import get_configured_user_id_lookup from app.application.messaging.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue from app.application.messaging.agent import agent_interaction_manager from app.application.messaging.agent import ( @@ -187,7 +188,7 @@ class _WebAgentEventPublisher: self._pending_signal.clear() -def _ensure_superuser(user: User) -> None: +def _ensure_superuser(user: ApiPrincipal) -> None: """校验当前用户是否为超级管理员。""" if not getattr(user, "is_superuser", False): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") @@ -199,7 +200,7 @@ def _ensure_superuser(user: User) -> None: response_model=_SchemaResponse[_SchemaAgentMcpServerListData], ) async def list_agent_mcp_servers( - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> _SchemaResponse: """ 查询 Agent 外部 MCP 服务器配置。 @@ -224,7 +225,7 @@ async def list_agent_mcp_servers( ) async def save_agent_mcp_servers( request: _SchemaAgentMcpServersSaveRequest, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> _SchemaResponse: """ 保存 Agent 外部 MCP 服务器配置。 @@ -244,7 +245,7 @@ async def save_agent_mcp_servers( ) async def test_agent_mcp_server( request: _SchemaAgentMcpServerTestRequest, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> _SchemaResponse: """ 测试 Agent 外部 MCP 服务器连接并读取工具列表。 @@ -431,7 +432,7 @@ class _WebAgentMoviePilotAgentMixin: if not self.user_id: return False try: - user = await UserOper().async_get_by_id(int(self.user_id)) + user = get_configured_user_id_lookup()(int(self.user_id)) except (TypeError, ValueError): return False except Exception as e: @@ -486,7 +487,7 @@ def _get_web_agent_type() -> type: return _WEB_AGENT_TYPE -def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str: +def _build_web_agent_session_id(user: ApiPrincipal, session_id: Optional[str]) -> str: """ 构建前端 Agent 会话 ID。 @@ -498,8 +499,8 @@ def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str: if seed.startswith(WEB_AGENT_SESSION_PREFIX): return seed try: - existing_chat = AgentChatOper().get(session_id=seed) - if existing_chat and _can_access_agent_chat(existing_chat, user): + existing_chat = get_configured_agent_chat_service().get_sync(seed) + if existing_chat and AgentChatService.can_access(existing_chat, user): return seed except Exception as e: logger.debug(f"读取WebAgent历史会话失败: {e}") @@ -508,7 +509,7 @@ def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str: return f"{WEB_AGENT_SESSION_PREFIX}{digest[:32]}" -def _can_access_agent_chat(chat: AgentChat, user: User) -> bool: +def _can_access_agent_chat(chat: Any, user: ApiPrincipal) -> bool: """ 判断当前登录用户是否可以访问指定 Agent 会话。 @@ -524,15 +525,14 @@ def _can_access_agent_chat(chat: AgentChat, user: User) -> bool: async def _get_accessible_agent_chat( - oper: AgentChatOper, session_id: str, user: User -) -> Optional[AgentChat]: + service: AgentChatService, + session_id: str, + user: ApiPrincipal, +) -> Optional[AgentChatRecord]: """ 读取当前用户可访问的 Agent 会话。 """ - chat = await oper.async_get(session_id=session_id) - if not chat or not _can_access_agent_chat(chat, user): - return None - return chat + return await service.get_accessible(session_id, user) def _append_web_agent_text_segment(assistant_message: dict, content: str) -> None: @@ -631,7 +631,7 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None def _save_web_agent_display_snapshot( *, session_id: str, - current_user: User, + current_user: ApiPrincipal, messages: list[dict], client_session_id: Optional[str] = None, ) -> None: @@ -639,9 +639,9 @@ def _save_web_agent_display_snapshot( 保存 WebAgent 当前展示消息快照。 """ try: - oper = AgentChatOper() - existing_chat = oper.get(session_id=session_id) - AgentChatOper().save_display_messages( + service = get_configured_agent_chat_service() + existing_chat = service.get_sync(session_id) + service.save_display_sync( session_id=session_id, user_id=(existing_chat.user_id if existing_chat else str(current_user.id)), username=(existing_chat.username if existing_chat else current_user.name), @@ -716,7 +716,7 @@ def _sanitize_web_agent_upload_name( return safe_name -def _get_web_agent_upload_dir(user: User, session_id: Optional[str]) -> Path: +def _get_web_agent_upload_dir(user: ApiPrincipal, session_id: Optional[str]) -> Path: """ 计算当前 Web Agent 会话的临时附件目录。 @@ -1425,7 +1425,7 @@ def _get_web_agent_unknown_command_message(text: str) -> Optional[str]: return f"命令不存在:{command}" -def _ensure_web_agent_command_allowed(current_user: User) -> Optional[str]: +def _ensure_web_agent_command_allowed(current_user: ApiPrincipal) -> Optional[str]: """ 校验当前 Web 用户是否可以执行传统斜杠命令。 @@ -1440,7 +1440,7 @@ def _ensure_web_agent_command_allowed(current_user: User) -> Optional[str]: async def _collect_web_agent_traditional_events( *, text: str, - current_user: User, + current_user: ApiPrincipal, original_message_id: Optional[Union[str, int]] = None, original_chat_id: Optional[Union[str, int]] = None, ) -> list[dict]: @@ -1637,7 +1637,7 @@ async def download_web_agent_file(file_id: str) -> FileResponse: async def upload_web_agent_file( file: UploadFile = File(...), session_id: Optional[str] = Form(None), - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> _SchemaResponse: """ 上传 Web 智能助手对话附件。 @@ -1680,7 +1680,7 @@ async def upload_web_agent_file( ) async def web_agent_callback( payload: _SchemaAgentWebChoiceRequest, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> _SchemaResponse: """ 接收 Web 智能助手选择卡片回调。 @@ -1717,7 +1717,7 @@ async def web_agent_callback( response_model=_SchemaResponse[list[_SchemaAgentWebCommandInfo]], ) async def list_web_agent_commands( - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> _SchemaResponse: """ 获取当前 Web 智能助手可补全的斜杠命令。 @@ -1737,8 +1737,8 @@ async def list_web_agent_commands( response_model=_SchemaResponse[list[_SchemaAgentChatSessionSummary]], ) async def list_agent_chat_sessions( - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_async_db), + current_user: ApiPrincipal = Depends(get_current_active_user), + service: AgentChatService = Depends(get_agent_chat_service), page: Optional[int] = 1, count: Optional[int] = 30, ) -> _SchemaResponse: @@ -1746,23 +1746,17 @@ async def list_agent_chat_sessions( 获取当前用户可访问的 Agent 历史会话列表。 :param current_user: 当前登录用户 - :param db: 异步数据库会话 + :param service: Agent 会话应用服务 :param page: 页码 :param count: 每页数量 :return: 会话摘要列表 """ - user_id = None if current_user.is_superuser else str(current_user.id) - username = None if current_user.is_superuser else current_user.name - chats = await AgentChatOper(db).async_list_by_page( + chats = await service.list( + current_user, page=page, count=count, - user_id=user_id, - username=username, - ) - return _SchemaResponse( - success=True, - data=[AgentChatOper.to_summary(chat) for chat in chats], ) + return _SchemaResponse(success=True, data=chats) @router.get( @@ -1772,24 +1766,27 @@ async def list_agent_chat_sessions( ) async def get_agent_chat_session( session_id: str, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_async_db), + current_user: ApiPrincipal = Depends(get_current_active_user), + service: AgentChatService = Depends(get_agent_chat_service), ) -> _SchemaResponse: """ 获取一条 Agent 历史会话详情。 :param session_id: Agent 会话 ID :param current_user: 当前登录用户 - :param db: 异步数据库会话 + :param service: Agent 会话应用服务 :return: 会话详情 """ - oper = AgentChatOper(db) - chat = await _get_accessible_agent_chat(oper, session_id, current_user) + chat = await _get_accessible_agent_chat(service, session_id, current_user) server_session_id = session_id if not chat: server_session_id = _build_web_agent_session_id(current_user, session_id) if server_session_id != session_id: - chat = await _get_accessible_agent_chat(oper, server_session_id, current_user) + chat = await _get_accessible_agent_chat( + service, + server_session_id, + current_user, + ) if not chat: manager = get_running_agent_manager() if manager and manager.is_session_busy(server_session_id): @@ -1803,7 +1800,7 @@ async def get_agent_chat_session( }, ) return _SchemaResponse(success=False, message="会话不存在或无权访问") - data = AgentChatOper.to_detail(chat) + data = service.to_detail(chat).model_dump() manager = get_running_agent_manager() data["is_processing"] = bool( manager and manager.is_session_busy(chat.session_id) @@ -1819,8 +1816,8 @@ async def get_agent_chat_session( async def save_agent_chat_display( session_id: str, payload: _SchemaAgentChatDisplaySaveRequest, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_async_db), + current_user: ApiPrincipal = Depends(get_current_active_user), + service: AgentChatService = Depends(get_agent_chat_service), ) -> _SchemaResponse: """ 保存前端聚合后的 Agent 展示消息。 @@ -1828,12 +1825,15 @@ async def save_agent_chat_display( :param session_id: Agent 会话 ID :param payload: 展示消息保存请求 :param current_user: 当前登录用户 - :param db: 异步数据库会话 + :param service: Agent 会话应用服务 :return: 保存后的会话摘要 """ - oper = AgentChatOper(db) - existing_chat = await oper.async_get(session_id=session_id) - if existing_chat and not _can_access_agent_chat(existing_chat, current_user): + existing_chat = await service.get_accessible(session_id, current_user) + if existing_chat is None: + unrestricted_chat = await service.get(session_id) + else: + unrestricted_chat = existing_chat + if unrestricted_chat and existing_chat is None: return _SchemaResponse(success=False, message="会话不存在或无权访问") messages = [ @@ -1847,10 +1847,10 @@ async def save_agent_chat_display( messages=messages, client_session_id=existing_chat.client_session_id if existing_chat else session_id, ) - chat = await oper.async_get(session_id=session_id) + chat = await service.get_accessible(session_id, current_user) if not chat: return _SchemaResponse(success=False, message="会话保存失败") - return _SchemaResponse(success=True, data=AgentChatOper.to_summary(chat)) + return _SchemaResponse(success=True, data=service.to_summary(chat)) @router.delete( @@ -1860,22 +1860,21 @@ async def save_agent_chat_display( ) async def delete_agent_chat_session( session_id: str, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_async_db), + current_user: ApiPrincipal = Depends(get_current_active_user), + service: AgentChatService = Depends(get_agent_chat_service), ) -> _SchemaResponse: """ 删除一条 Agent 历史会话。 :param session_id: Agent 会话 ID :param current_user: 当前登录用户 - :param db: 异步数据库会话 + :param service: Agent 会话应用服务 :return: 删除结果 """ - oper = AgentChatOper(db) - chat = await _get_accessible_agent_chat(oper, session_id, current_user) + chat = await _get_accessible_agent_chat(service, session_id, current_user) if not chat: return _SchemaResponse(success=False, message="会话不存在或无权访问") - deleted = await oper.async_delete(session_id=session_id) + deleted = await service.delete(session_id, current_user) return _SchemaResponse(success=deleted, message="删除成功" if deleted else "删除失败") @@ -1886,23 +1885,25 @@ async def delete_agent_chat_session( ) async def stop_web_agent_session_task( session_id: str, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_async_db), + current_user: ApiPrincipal = Depends(get_current_active_user), + service: AgentChatService = Depends(get_agent_chat_service), ) -> _SchemaResponse: """ 停止当前 Web 智能助手会话正在执行的任务。 :param session_id: Agent 会话 ID :param current_user: 当前登录用户 - :param db: 异步数据库会话 + :param service: Agent 会话应用服务 :return: 停止结果 """ server_session_id = _build_web_agent_session_id(current_user, session_id) chat = await _get_accessible_agent_chat( - AgentChatOper(db), server_session_id, current_user + service, + server_session_id, + current_user, ) if not chat and server_session_id != session_id: - chat = await _get_accessible_agent_chat(AgentChatOper(db), session_id, current_user) + chat = await _get_accessible_agent_chat(service, session_id, current_user) if chat and not _can_access_agent_chat(chat, current_user): return _SchemaResponse(success=False, message="会话不存在或无权访问") @@ -1930,7 +1931,7 @@ async def stop_web_agent_session_task( async def web_agent_stream( payload: _SchemaAgentWebChatRequest, request: Request, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> StreamingResponse: """ Web 智能助手流式对话。 diff --git a/app/api/endpoints/anilist.py b/app/api/endpoints/anilist.py index fdd7b7057..6857920ab 100644 --- a/app/api/endpoints/anilist.py +++ b/app/api/endpoints/anilist.py @@ -8,7 +8,7 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.anilist import AniListChain from app.domain.context import MediaInfo -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token router = ResponseAPIRouter() diff --git a/app/api/endpoints/anthropic.py b/app/api/endpoints/anthropic.py index 723704742..67b389c09 100644 --- a/app/api/endpoints/anthropic.py +++ b/app/api/endpoints/anthropic.py @@ -23,7 +23,7 @@ from app.api.openai_utils import ( ) from app.agent.runtime_loader import get_running_agent_manager from app.runtime.config import settings -from app.application.security.access import anthropic_api_key_header +from app.adapters.web.security.access import anthropic_api_key_header ANTHROPIC_ERROR_RESPONSES = { 400: {"model": _SchemaAnthropicErrorResponse, "description": "请求格式错误"}, diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py index 207286316..f17df85ac 100644 --- a/app/api/endpoints/auth.py +++ b/app/api/endpoints/auth.py @@ -1,15 +1,14 @@ from typing import Any -from fastapi import HTTPException +from fastapi import Depends, HTTPException from pydantic import BaseModel from app.schemas.token import Token as _SchemaToken from app.schemas.user import AuthProviderInfo as _SchemaAuthProviderInfo from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter -from app.application.security.auth import build_token_response, consume_plugin_auth_ticket -from app.runtime.extensions.plugin_manager import PluginManager -from app.db.models.passkey import PassKey -from app.db.models.user import User +from app.application.security.auth import AuthService, consume_plugin_auth_ticket +from app.application.plugin.runtime import get_plugin_manager as PluginManager +from app.api.deps import get_auth_service router = ResponseAPIRouter() @@ -22,13 +21,13 @@ class AuthExchangeRequest(BaseModel): ticket: str -def _system_auth_providers() -> list[dict[str, Any]]: +def _system_auth_providers(service: AuthService) -> list[dict[str, Any]]: """ 获取系统内建的匿名登录方式摘要。 :return: 系统认证提供方列表 """ - has_passkey = bool(PassKey.list(db=None)) + has_passkey = service.has_passkey() return [ { "id": "system:passkey", @@ -46,13 +45,13 @@ def _system_auth_providers() -> list[dict[str, Any]]: summary="查询登录认证提供方", response_model=list[_SchemaAuthProviderInfo], ) -def auth_providers() -> list[dict[str, Any]]: +def auth_providers(service: AuthService = Depends(get_auth_service)) -> list[dict[str, Any]]: """ 查询系统和插件提供的登录认证入口。 :return: 认证提供方摘要列表 """ - providers = _system_auth_providers() + providers = _system_auth_providers(service) providers.extend(PluginManager().get_plugin_auth_providers()) return [provider for provider in providers if provider.get("enabled", True)] @@ -63,7 +62,10 @@ def auth_providers() -> list[dict[str, Any]]: response_model=_SchemaToken, openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True}, ) -def auth_exchange(body: AuthExchangeRequest) -> _SchemaToken: +def auth_exchange( + body: AuthExchangeRequest, + service: AuthService = Depends(get_auth_service), +) -> _SchemaToken: """ 将插件认证成功后生成的一次性票据兑换为系统 Token。 @@ -74,8 +76,8 @@ def auth_exchange(body: AuthExchangeRequest) -> _SchemaToken: if not ticket_data: raise HTTPException(status_code=401, detail="认证票据无效或已过期") - user = User.get(db=None, rid=ticket_data.get("user_id")) + user = service.get_user_by_id(ticket_data.get("user_id")) if not user or not user.is_active: raise HTTPException(status_code=403, detail="用户不存在或已禁用") - return build_token_response(user) + return service.build_token_response(user) diff --git a/app/api/endpoints/bangumi.py b/app/api/endpoints/bangumi.py index 6aad4908f..c3a594e4a 100644 --- a/app/api/endpoints/bangumi.py +++ b/app/api/endpoints/bangumi.py @@ -8,7 +8,7 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.bangumi import BangumiChain from app.domain.context import MediaInfo -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token router = ResponseAPIRouter() diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index 67057838d..20d9b18cd 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import Any, List, Optional, Annotated from fastapi import Depends -from sqlalchemy.orm import Session from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo @@ -17,53 +16,17 @@ from app.api.response import ResponseAPIRouter from app.chain.dashboard import DashboardChain from app.chain.storage import StorageChain from app.runtime.config import settings -from app.application.security.access import verify_apitoken -from app.db import get_db -from app.db.models.transferhistory import TransferHistory -from app.api.deps import get_current_active_superuser +from app.adapters.web.security.access import verify_apitoken +from app.api.deps import get_current_active_superuser, get_dashboard_query_service +from app.application.dashboard import DashboardQueryService from app.schemas.types import StorageAction from app.application.directory import DirectoryHelper -from app.scheduler import Scheduler +from app.application.scheduling import Scheduler from app.adapters.system.host import SystemUtils router = ResponseAPIRouter() -def _build_statistic(db: Session, name: Optional[str] = None) -> _SchemaStatistic: - """ - 构建媒体数量统计信息。 - """ - media_statistics: Optional[List[_SchemaStatistic]] = ( - DashboardChain().media_statistic(name) - ) - if media_statistics: - # 汇总各媒体库统计信息 - ret_statistic = _SchemaStatistic() - has_episode_count = False - for media_statistic in media_statistics: - ret_statistic.movie_count += media_statistic.movie_count or 0 - ret_statistic.tv_count += media_statistic.tv_count or 0 - ret_statistic.music_count += media_statistic.music_count or 0 - ret_statistic.user_count += media_statistic.user_count or 0 - if media_statistic.episode_count is not None: - ret_statistic.episode_count += media_statistic.episode_count or 0 - has_episode_count = True - if not has_episode_count: - # 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。 - ret_statistic.episode_count = None - else: - ret_statistic = _SchemaStatistic() - - movie_count_month, tv_count_month, episode_count_month, music_count_month = ( - TransferHistory.monthly_media_statistics(db) - ) - ret_statistic.movie_count_month = movie_count_month - ret_statistic.tv_count_month = tv_count_month - ret_statistic.episode_count_month = episode_count_month - ret_statistic.music_count_month = music_count_month - return ret_statistic - - def _build_storage() -> _SchemaStorage: """ 构建本地存储空间信息。 @@ -114,13 +77,13 @@ def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo: @router.get("/statistic", summary="媒体数量统计", response_model=_SchemaStatistic) def statistic( name: Optional[str] = None, - db: Session = Depends(get_db), + service: DashboardQueryService = Depends(get_dashboard_query_service), _: Any = Depends(get_current_active_superuser), ) -> Any: """ 查询媒体数量统计信息 """ - return _build_statistic(db, name) + return service.statistic(name) @router.get( @@ -128,12 +91,12 @@ def statistic( ) def statistic2( _: Annotated[str, Depends(verify_apitoken)], - db: Session = Depends(get_db), + service: DashboardQueryService = Depends(get_dashboard_query_service), ) -> Any: """ 查询媒体数量统计信息 API_TOKEN认证(?token=xxx) """ - return _build_statistic(db) + return service.statistic() @router.get("/storage", summary="本地存储空间", response_model=_SchemaStorage) @@ -249,14 +212,13 @@ async def schedule_progress2( @router.get("/transfer", summary="文件整理统计", response_model=List[int]) async def transfer( days: Optional[int] = 7, - db: Session = Depends(get_db), + service: DashboardQueryService = Depends(get_dashboard_query_service), _: Any = Depends(get_current_active_superuser), ) -> Any: """ 查询文件整理统计信息 """ - transfer_stat = await TransferHistory.async_statistic(db, days) - return [stat[1] for stat in transfer_stat] + return await service.transfer(days) @router.get("/cpu", summary="获取当前CPU使用率", response_model=float) diff --git a/app/api/endpoints/discover.py b/app/api/endpoints/discover.py index ea7adc0f3..a899c600d 100644 --- a/app/api/endpoints/discover.py +++ b/app/api/endpoints/discover.py @@ -10,7 +10,7 @@ from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain from app.runtime.events import eventmanager -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token from app.schemas.event import DiscoverSourceEventData from app.schemas.types import ChainEventType, MediaType diff --git a/app/api/endpoints/douban.py b/app/api/endpoints/douban.py index 9e0f68b81..3931b8495 100644 --- a/app/api/endpoints/douban.py +++ b/app/api/endpoints/douban.py @@ -8,7 +8,7 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.douban import DoubanChain from app.domain.context import MediaInfo -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token from app.schemas.types import MediaType router = ResponseAPIRouter() diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index b4023f0f2..cdc2df0ed 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -20,11 +20,14 @@ from app.chain.media import MediaChain from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo -from app.application.security.access import verify_token -from app.db.models.user import User -from app.db.oper.site import SiteOper -from app.db.oper.systemconfig import SystemConfigOper -from app.api.deps import get_current_active_user +from app.adapters.web.security.access import verify_token +from app.api.principal import ApiPrincipal +from app.application.configuration import get_configured_system_config +from app.application.site.query import ( + SiteQueryService, + get_configured_site_query_service, +) +from app.api.deps import get_current_active_user, get_site_sync_query_service from app.application.directory import DirectoryHelper from app.schemas.types import ( MUSIC_ENTITY_RECORDING, @@ -39,7 +42,10 @@ from app.application.security.url import SecurityUtils router = ResponseAPIRouter() -def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]: +def _prepare_subtitle_download( + subtitle: SubtitleInfo, + query: SiteQueryService | None = None, +) -> tuple[bool, str]: """ 校验字幕下载签名,并用服务端站点配置覆盖请求凭据。 """ @@ -53,7 +59,8 @@ def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]: if not clean_url: return False, "字幕下载链接签名无效" - site = SiteOper().get(subtitle.site) + site_query = query or get_configured_site_query_service() + site = site_query.get_sync(subtitle.site) if not site: return False, "字幕站点信息不存在" @@ -84,7 +91,7 @@ def download( torrent_in: _SchemaTorrentInfo, downloader: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: """ 添加下载任务(含媒体信息) @@ -130,7 +137,7 @@ def add( downloader: Annotated[str | None, Body()] = None, # 保存路径, 支持:, 如rclone:/MP, smb:/server/share/Movies等 save_path: Annotated[str | None, Body()] = None, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: """ 添加下载任务(不含媒体信息) @@ -213,14 +220,20 @@ def download_subtitle( media_source: Annotated[MediaSource, Body()], media_id: Annotated[str, Body()], save_path: Annotated[str | None, Body()] = None, - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), + query: SiteQueryService = Depends(get_site_sync_query_service), ) -> Any: """ 下载字幕资源。 """ subtitle_info = SubtitleInfo() subtitle_info.from_dict(subtitle_in.model_dump()) - valid, message = _prepare_subtitle_download(subtitle_info) + # 直接调用 endpoint 的旧测试/插件入口不会经过 FastAPI 依赖解析;此时让 + # 应用查询端口自行提供服务,仍保留真实请求中的注入对象。 + if not hasattr(query, "get_sync"): + valid, message = _prepare_subtitle_download(subtitle_info) + else: + valid, message = _prepare_subtitle_download(subtitle_info, query) if not valid: return _SchemaResponse(success=False, message=message) @@ -273,7 +286,7 @@ async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询可用下载器 """ - downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders) + downloaders: List[dict] = get_configured_system_config().get(SystemConfigKey.Downloaders) if downloaders: return [ {"name": d.get("name"), "type": d.get("type")} diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 69cc7b352..e6cada697 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -3,8 +3,6 @@ import time from typing import List, Any, Optional from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session from app.schemas.common import BatchProgressKeyData as _SchemaBatchProgressKeyData from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData @@ -22,23 +20,20 @@ from app.agent.prompt.transfer_redo import ( build_manual_redo_prompt, ) from app.runtime.config import settings, global_vars -from app.application.security.access import verify_token -from app.db import get_async_db, get_db -from app.db.models import User -from app.db.models.downloadhistory import DownloadHistory -from app.db.models.transferhistory import TransferHistory +from app.adapters.web.security.access import verify_token from app.api.deps import ( get_current_active_manage_user, get_current_active_superuser, get_download_history_mutation_command, + get_history_query_service, get_transfer_history_mutation_command, ) from app.runtime.progress import ProgressHelper from app.application.history import ( DownloadHistoryMutationCommand, + HistoryQueryService, TransferHistoryMutationCommand, ) -from app.foundation.text import cut as jieba_cut from app.runtime.log import logger router = ResponseAPIRouter() @@ -155,13 +150,13 @@ def _start_batch_ai_redo_task( async def download_history( page: Optional[int] = 1, count: Optional[int] = 30, - db: AsyncSession = Depends(get_async_db), + query: HistoryQueryService = Depends(get_history_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 按下载时间倒序查询下载历史记录 """ - return await DownloadHistory.async_list_by_page(db, page, count) + return await query.list_download(page=page, count=count) @router.delete( @@ -183,14 +178,6 @@ def delete_download_history( return _SchemaResponse(success=result.success, message=result.message) -def _glob_to_like(pattern: str) -> str: - """ - 将 glob 通配符模式转换为 SQL LIKE 模式(使用 \\ 作为转义字符) - """ - result = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - return result.replace("*", "%").replace("?", "_") - - @router.get( "/transfer", summary="查询整理记录", @@ -201,50 +188,19 @@ async def transfer_history( page: Optional[int] = 1, count: Optional[int] = 30, status: Optional[bool] = None, - db: AsyncSession = Depends(get_async_db), + query: HistoryQueryService = Depends(get_history_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询整理记录,title 支持通配符 * 和 ?(如 *.mkv、*2024*) """ - if title == "失败": - title = None - status = False - elif title == "成功": - title = None - status = True - - if title: - if "*" in title or "?" in title: - like_pattern = _glob_to_like(title) - total = await TransferHistory.async_count_by_title( - db, title=like_pattern, status=status, wildcard=True - ) - result = await TransferHistory.async_list_by_title( - db, title=like_pattern, page=page, count=count, status=status, wildcard=True - ) - else: - words = jieba_cut(title, HMM=False) - like_pattern = "%".join(words) - total = await TransferHistory.async_count_by_title( - db, title=like_pattern, status=status - ) - result = await TransferHistory.async_list_by_title( - db, title=like_pattern, page=page, count=count, status=status - ) - else: - result = await TransferHistory.async_list_by_page( - db, page=page, count=count, status=status - ) - total = await TransferHistory.async_count(db, status=status) - - return _SchemaResponse( - success=True, - data={ - "list": [item.to_dict() for item in result], - "total": total, - }, + result = await query.list_transfer( + title=title, + page=page, + count=count, + status=status, ) + return _SchemaResponse(success=True, data=result) @router.delete("/transfer", summary="删除整理记录", response_model=_SchemaResponse[None]) @@ -255,7 +211,7 @@ def delete_transfer_history( command: TransferHistoryMutationCommand = Depends( get_transfer_history_mutation_command ), - _: User = Depends(get_current_active_manage_user), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 删除整理记录。 @@ -273,10 +229,10 @@ def delete_transfer_history( summary="智能助手重新整理", response_model=_SchemaResponse[_SchemaProgressKeyData], ) -def ai_redo_transfer_history( +async def ai_redo_transfer_history( history_id: int, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + query: HistoryQueryService = Depends(get_history_query_service), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 手动触发单条历史记录的 AI 重新整理,并返回进度键。 @@ -284,7 +240,7 @@ def ai_redo_transfer_history( if not settings.AI_AGENT_ENABLE: return _SchemaResponse(success=False, message="MoviePilot智能助手未启用") - history = TransferHistory.get(db, history_id) + history = await query.get_transfer(history_id) if not history: return _SchemaResponse(success=False, message="整理记录不存在") @@ -304,10 +260,10 @@ def ai_redo_transfer_history( summary="智能助手批量重新整理", response_model=_SchemaResponse[_SchemaBatchProgressKeyData], ) -def batch_ai_redo_transfer_history( +async def batch_ai_redo_transfer_history( payload: _SchemaBatchTransferHistoryRedoRequest, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + query: HistoryQueryService = Depends(get_history_query_service), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 手动触发多条历史记录的 AI 批量重新整理,并返回进度键。 @@ -319,14 +275,7 @@ def batch_ai_redo_transfer_history( if not history_ids: return _SchemaResponse(success=False, message="未提供有效的整理记录") - histories = [] - missing_ids = [] - for history_id in history_ids: - history = TransferHistory.get(db, history_id) - if not history: - missing_ids.append(history_id) - continue - histories.append(history) + histories, missing_ids = await query.get_transfers(history_ids) if missing_ids: return _SchemaResponse( @@ -358,7 +307,7 @@ def empty_transfer_history( command: TransferHistoryMutationCommand = Depends( get_transfer_history_mutation_command ), - _: User = Depends(get_current_active_superuser), + _: object = Depends(get_current_active_superuser), ) -> Any: """ 清空整理记录 diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 609febfb0..03942ae4e 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -6,7 +6,6 @@ from fastapi.responses import HTMLResponse from app.schemas.common import ManageRequest as _SchemaManageRequest from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter -from app.db.models import User from app.api.deps import get_current_active_superuser_async router = ResponseAPIRouter() @@ -29,7 +28,7 @@ def _get_llm_provider_manager_type() -> type: async def manage_provider( request: Request, payload: _SchemaManageRequest, - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ): """ LLM 提供商统一管理入口:前端上送 target/action/params 原样透传, diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index 0831b2caa..cc5239093 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -11,9 +11,10 @@ from app.schemas.token import Token as _SchemaToken from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.chain.user import MfaRequired, UserChain -from app.application.security import access as security +from app.adapters.web.security.access import set_or_refresh_resource_token_cookie +from app.application.security.token import create_access_token from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.application.image import WallpaperHelper from app.schemas.types import SystemConfigKey @@ -67,17 +68,17 @@ def login_access_token( level = SitesHelper().auth_level # 是否显示配置向导 show_wizard = ( - not SystemConfigOper().get(SystemConfigKey.SetupWizardState) + not get_configured_system_config().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE ) - access_token = security.create_access_token( + access_token = create_access_token( userid=user_or_message.id, username=user_or_message.name, super_user=user_or_message.is_superuser, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), level=level, ) - security.set_or_refresh_resource_token_cookie( + set_or_refresh_resource_token_cookie( request, response, _SchemaTokenPayload( diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index e9a23e294..8e9f598e8 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -13,7 +13,7 @@ from app.schemas.mcp import ToolCallRequest as _SchemaToolCallRequest from app.schemas.response import Response as _SchemaResponse from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.agent.tools.manager import moviepilot_tool_manager -from app.application.security.access import verify_apikey +from app.adapters.web.security.access import verify_apikey from app.runtime.log import logger # 导入版本号 diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 2f9d8da2d..c4df4753d 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -25,8 +25,7 @@ from app.domain.context import Context, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath -from app.application.security.access import verify_token, verify_apitoken -from app.db.models import User +from app.adapters.web.security.access import verify_token, verify_apitoken from app.api.deps import get_current_active_user, get_current_active_superuser from app.schemas.category import CategoryConfig from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType @@ -440,7 +439,7 @@ def scrape( summary="获取分类策略配置", response_model=_SchemaResponse[_SchemaCategoryConfig], ) -def get_category_config(_: User = Depends(get_current_active_user)): +def get_category_config(_: object = Depends(get_current_active_user)): """ 获取分类策略配置 """ @@ -452,7 +451,7 @@ def get_category_config(_: User = Depends(get_current_active_user)): "/category/config", summary="保存分类策略配置", response_model=_SchemaResponse[None] ) def save_category_config( - config: CategoryConfig, _: User = Depends(get_current_active_superuser) + config: CategoryConfig, _: object = Depends(get_current_active_superuser) ): """ 保存分类策略配置 diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index f48230ae4..3eb879fcc 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -1,7 +1,6 @@ from typing import Any, List, Optional from fastapi import Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.common import ServiceClientInfo as _SchemaServiceClientInfo from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo @@ -19,12 +18,10 @@ from app.chain.download import DownloadChain from app.chain.mediaserver import MediaServerChain from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo -from app.application.security.access import verify_token -from app.db import get_async_db -from app.db.oper.mediaserver import MediaServerOper -from app.db.models import MediaServerItem -from app.db.oper.systemconfig import SystemConfigOper -from app.application.mediaserver import MediaServerHelper +from app.adapters.web.security.access import verify_token +from app.application.configuration import get_configured_system_config +from app.application.mediaserver import MediaServerHelper, MediaServerQueryService +from app.api.deps import get_mediaserver_query_service from app.schemas.mediaserver import NotExistMediaInfo from app.schemas.types import MediaSource, MediaType, SystemConfigKey from app.schemas.media import build_media_key, resolve_media_identity @@ -90,7 +87,7 @@ async def exists_local( media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, season: Optional[int] = None, - db: AsyncSession = Depends(get_async_db), + service: MediaServerQueryService = Depends(get_mediaserver_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ @@ -107,7 +104,7 @@ async def exists_local( # 返回对象 ret_info = {} # 本地数据库是否存在 - exist: MediaServerItem = await MediaServerOper(db).async_exists( + item_id = await service.find_item_id( title=meta.name if meta else None, year=year, mtype=mtype, @@ -115,8 +112,8 @@ async def exists_local( media_id=media_id, season=season, ) - if exist: - ret_info = {"id": exist.item_id} + if item_id: + ret_info = {"id": item_id} return _SchemaResponse(success=True, data={"item": ret_info}) @@ -251,7 +248,7 @@ async def clients(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询可用媒体服务器 """ - mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers) + mediaservers: List[dict] = get_configured_system_config().get(SystemConfigKey.MediaServers) if mediaservers: return [ {"name": d.get("name"), "type": d.get("type")} diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index e0e7f2e0f..44dde689d 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -5,7 +5,6 @@ import time from typing import Protocol, Union, Any, List, Optional from fastapi import BackgroundTasks, Depends, Request -from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import PlainTextResponse from app.schemas.message import MessageClearBefore as _SchemaMessageClearBefore @@ -20,13 +19,12 @@ from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import ResponseAPIRouter from app.chain.message import MessageChain from app.runtime.config import settings, global_vars -from app.application.security.access import verify_token, verify_apitoken -from app.db import get_async_db -from app.db.models import User -from app.db.oper.message import MessageOper -from app.db.oper.systemconfig import SystemConfigOper -from app.api.deps import get_current_active_superuser -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.adapters.web.security.access import verify_token, verify_apitoken +from app.api.principal import ApiPrincipal +from app.application.configuration import get_configured_system_config +from app.api.deps import get_current_active_superuser, get_message_query_service +from app.application.messaging.message import MessageQueryService +from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.schemas.types import NotificationChannel, SystemConfigKey @@ -83,7 +81,7 @@ def _get_notification_clear_before() -> _SchemaMessageClearBefore: """ 读取通知中心清理时间配置。 """ - value = SystemConfigOper().get(SystemConfigKey.NotificationClearBefore) + value = get_configured_system_config().get(SystemConfigKey.NotificationClearBefore) if isinstance(value, dict): return _SchemaMessageClearBefore( all=_normalize_notification_clear_timestamp(value.get("all")), @@ -156,7 +154,7 @@ async def user_message( async def web_message( request: Request, text: Optional[str] = None, - current_user: User = Depends(get_current_active_superuser), + current_user: ApiPrincipal = Depends(get_current_active_superuser), ): """ WEB消息响应 @@ -194,28 +192,20 @@ async def web_message( @router.get("/web", summary="获取WEB消息", response_model=List[_SchemaWebMessageItem]) async def get_web_message( _: _SchemaTokenPayload = Depends(verify_token), - db: AsyncSession = Depends(get_async_db), + service: MessageQueryService = Depends(get_message_query_service), page: Optional[int] = 1, count: Optional[int] = 20, ): """ 获取WEB消息列表 """ - ret_messages = [] - messages = await MessageOper(db).async_list_by_page(page=page, count=count) - for message in messages: - try: - ret_messages.append(message.to_dict()) - except Exception as e: - logger.error(f"获取WEB消息列表失败: {str(e)}") - continue - return ret_messages + return await service.list_web(page=page, count=count) @router.get("/notification", summary="获取通知消息", response_model=List[_SchemaMessageHistoryItem]) async def get_notification_message( _: _SchemaTokenPayload = Depends(verify_token), - db: AsyncSession = Depends(get_async_db), + service: MessageQueryService = Depends(get_message_query_service), page: Optional[int] = 1, count: Optional[int] = 20, ): @@ -223,14 +213,14 @@ async def get_notification_message( 获取系统发送的通知消息列表。 """ clear_before = _get_notification_clear_before() - messages = await MessageOper(db).async_list_sent_by_page( + messages = await service.list_notifications( page=page, count=count, all_clear_before=_format_notification_clear_time(clear_before.all), system_clear_before=_format_notification_clear_time(clear_before.system), media_clear_before=_format_notification_clear_time(clear_before.media), ) - return [_SchemaMessageHistoryItem(**message.to_dict()) for message in messages] + return [_SchemaMessageHistoryItem(**message) for message in messages] @router.delete( @@ -248,7 +238,7 @@ async def clear_notification_message( clear_before = _get_notification_clear_before() value = clear_before.model_dump() value[scope.value] = int(time.time() * 1000) - await SystemConfigOper().async_set(SystemConfigKey.NotificationClearBefore, value) + await get_configured_system_config().async_set(SystemConfigKey.NotificationClearBefore, value) return _SchemaResponse(success=True, data={"clear_before": value}) diff --git a/app/api/endpoints/mfa.py b/app/api/endpoints/mfa.py index 35d9038fd..07ec7bfd8 100644 --- a/app/api/endpoints/mfa.py +++ b/app/api/endpoints/mfa.py @@ -4,12 +4,9 @@ MFA (Multi-Factor Authentication) API 端点 """ import json -from datetime import timedelta from typing import Any, Annotated, Optional -from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from fastapi import Depends, HTTPException, Body, Request, Response -from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.mcp import BaseModel as _SchemaBaseModel from app.schemas.mcp import JsonData as _SchemaJsonData @@ -21,13 +18,24 @@ from app.schemas.response import Response as _SchemaResponse from app.schemas.token import Token as _SchemaToken from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter -from app.application.security import access as security -from app.runtime.config import settings -from app.db import get_async_db -from app.db.models.passkey import PassKey -from app.db.models.user import User -from app.db.oper.systemconfig import SystemConfigOper -from app.api.deps import get_current_active_user, get_current_active_user_async +from app.adapters.web.security.access import set_or_refresh_resource_token_cookie +from app.application.security.token import verify_password +from app.application.security.auth import get_configured_auth_service +from app.application.security.user import UserService +from app.application.security.user import ( + get_configured_user_id_lookup, + get_configured_user_name_lookup, +) +from app.application.security.passkeys import ( + PasskeyService, +) +from app.api.principal import ApiPrincipal +from app.api.deps import ( + get_current_active_user, + get_current_active_user_async, + get_user_service, + get_passkey_service, +) from app.application.security.passkey import ( PassKeyHelper, PassKeyRegistrationOriginMismatchError, @@ -35,7 +43,6 @@ from app.application.security.passkey import ( PasskeyChallengeStore, ) from app.runtime.log import logger -from app.schemas.types import SystemConfigKey from app.application.security.otp import OtpUtils router = ResponseAPIRouter() @@ -43,7 +50,7 @@ router = ResponseAPIRouter() # ==================== 辅助函数 ==================== -def _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]: +def _build_credential_list(passkeys: list[Any]) -> list[dict[str, Any]]: """ 构建凭证列表 @@ -75,7 +82,10 @@ def _extract_and_standardize_credential_id(credential: dict) -> str: def _verify_passkey_and_update( - credential: dict, challenge: str, passkey: PassKey + credential: dict, + challenge: str, + passkey: Any, + service: PasskeyService, ) -> tuple[bool, int]: """ 验证 PassKey 并更新使用时间和签名计数 @@ -93,7 +103,7 @@ def _verify_passkey_and_update( ) if success: - passkey.update_last_used(db=None, sign_count=new_sign_count) + service.update_last_used(passkey, new_sign_count) return success, new_sign_count @@ -129,11 +139,14 @@ class PassKeyDeleteRequest(_SchemaBaseModel): summary="判断用户是否开启二次验证", response_model=_SchemaResponse[_SchemaMfaStatusData], ) -async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any: +async def mfa_status( + username: str, + service: UserService = Depends(get_user_service), +) -> Any: """ 检查指定用户是否启用了二次验证 """ - user: User = await User.async_get_by_name(db, username) + user = await service.get_by_name(username) if not user: return _SchemaResponse(success=False, message="用户不存在") @@ -152,7 +165,7 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> response_model=_SchemaResponse[_SchemaOtpGenerateData], ) def otp_generate( - current_user: Annotated[User, Depends(get_current_active_user)], + current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], ) -> Any: """生成 OTP 密钥及对应的 URI""" secret, uri = OtpUtils.generate_secret_key(current_user.name) @@ -162,14 +175,16 @@ def otp_generate( @router.post("/otp/verify", summary="绑定并验证 OTP", response_model=_SchemaResponse[None]) async def otp_verify( data: OtpVerifyRequest, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + service: UserService = Depends(get_user_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证""" if not OtpUtils.is_legal(data.uri, data.otpPassword): return _SchemaResponse(success=False, message="验证码错误") - await current_user.async_update_otp_by_name( - db, current_user.name, True, OtpUtils.get_secret(data.uri) + await service.update_otp( + current_user.name, + True, + OtpUtils.get_secret(data.uri), ) return _SchemaResponse(success=True) @@ -181,14 +196,14 @@ async def otp_verify( ) async def otp_disable( data: OtpDisableRequest, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + service: UserService = Depends(get_user_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """关闭当前用户的 OTP 验证功能""" # 验证密码 - if not security.verify_password(data.password, str(current_user.hashed_password)): + if not verify_password(data.password, str(current_user.hashed_password)): return _SchemaResponse(success=False, message="密码错误") - await current_user.async_update_otp_by_name(db, current_user.name, False, "") + await service.update_otp(current_user.name, False, "") return _SchemaResponse(success=True) @@ -228,12 +243,13 @@ class PassKeyAuthenticationFinish(_SchemaBaseModel): response_model=_SchemaResponse[_SchemaPasskeyStartData], ) def passkey_register_start( - current_user: Annotated[User, Depends(get_current_active_user)], + current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], + service: PasskeyService = Depends(get_passkey_service), ) -> Any: """开始注册 PassKey - 生成注册选项""" try: # 获取用户已有的PassKey - existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id) + existing_passkeys = service.list_by_user_id(current_user.id) existing_credentials = ( _build_credential_list(existing_passkeys) if existing_passkeys else None ) @@ -272,7 +288,8 @@ def passkey_register_start( ) def passkey_register_finish( passkey_req: PassKeyRegistrationFinish, - current_user: Annotated[User, Depends(get_current_active_user)], + current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], + service: PasskeyService = Depends(get_passkey_service), ) -> Any: """完成注册 PassKey - 验证并保存凭证""" try: @@ -303,16 +320,15 @@ def passkey_register_finish( transports = ",".join(passkey_req.credential["response"]["transports"]) # 保存到数据库 - passkey = PassKey( - user_id=current_user.id, - credential_id=credential_id, - public_key=public_key, - sign_count=sign_count, - name=passkey_req.name or "通行密钥", - aaguid=aaguid, - transports=transports, - ) - passkey.create() + service.create({ + "user_id": current_user.id, + "credential_id": credential_id, + "public_key": public_key, + "sign_count": sign_count, + "name": passkey_req.name or "通行密钥", + "aaguid": aaguid, + "transports": transports, + }) logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}") @@ -339,6 +355,7 @@ def passkey_register_finish( ) def passkey_authenticate_start( passkey_req: PassKeyAuthenticationStart = Body(...), + service: PasskeyService = Depends(get_passkey_service), ) -> Any: """开始 PassKey 认证 - 生成认证选项""" try: @@ -347,9 +364,9 @@ def passkey_authenticate_start( # 如果指定了用户名,只允许该用户的PassKey if passkey_req.username: - user = User.get_by_name(db=None, name=passkey_req.username) + user = get_configured_user_name_lookup()(passkey_req.username) existing_passkeys = ( - PassKey.get_by_user_id(db=None, user_id=user.id) if user else None + service.list_by_user_id(user.id) if user else None ) if not user or not existing_passkeys: @@ -387,7 +404,10 @@ def passkey_authenticate_start( openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True}, ) def passkey_authenticate_finish( - request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish + request: Request, + response: Response, + passkey_req: PassKeyAuthenticationFinish, + service: PasskeyService = Depends(get_passkey_service), ) -> Any: """完成 PassKey 认证 - 验证凭证并返回 token""" try: @@ -408,8 +428,8 @@ def passkey_authenticate_finish( raise HTTPException(status_code=401, detail="认证失败") # 查找PassKey并获取用户 - passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id) - user = User.get_by_id(db=None, user_id=passkey.user_id) if passkey else None + passkey = service.get_by_credential_id(credential_id) + user = get_configured_user_id_lookup()(passkey.user_id) if passkey else None if not passkey or not user or not user.is_active: raise HTTPException(status_code=401, detail="认证失败") if challenge_state.user_id is not None and challenge_state.user_id != user.id: @@ -420,6 +440,7 @@ def passkey_authenticate_finish( credential=passkey_req.credential, challenge=challenge_state.challenge, passkey=passkey, + service=service, ) if not success: @@ -428,42 +449,19 @@ def passkey_authenticate_finish( logger.info(f"用户 {user.name} 通过PassKey认证成功") # 生成token - level = SitesHelper().auth_level - show_wizard = ( - not SystemConfigOper().get(SystemConfigKey.SetupWizardState) - and not settings.ADVANCED_MODE - ) - - access_token = security.create_access_token( - userid=user.id, - username=user.name, - super_user=user.is_superuser, - expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), - level=level, - ) - security.set_or_refresh_resource_token_cookie( + token = get_configured_auth_service().build_token_response(user) + set_or_refresh_resource_token_cookie( request, response, _SchemaTokenPayload( sub=user.id, username=user.name, super_user=user.is_superuser, - level=level, + level=token.level, purpose="authentication", ), ) - - return _SchemaToken( - access_token=access_token, - token_type="bearer", - super_user=user.is_superuser, - user_id=user.id, - user_name=user.name, - avatar=user.avatar, - level=level, - permissions=user.permissions or {}, - wizard=show_wizard, - ) + return token except HTTPException: raise except Exception as e: @@ -477,11 +475,12 @@ def passkey_authenticate_finish( response_model=_SchemaResponse[list[_SchemaPasskeyInfo]], ) def passkey_list( - current_user: Annotated[User, Depends(get_current_active_user)], + current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], + service: PasskeyService = Depends(get_passkey_service), ) -> Any: """获取当前用户的所有 PassKey""" try: - passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id) + passkeys = service.list_by_user_id(current_user.id) key_list = ( [ @@ -514,19 +513,18 @@ def passkey_list( ) async def passkey_delete( data: PassKeyDeleteRequest, - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), + service: PasskeyService = Depends(get_passkey_service), ) -> Any: """删除指定的 PassKey""" try: # 验证密码 - if not security.verify_password( + if not verify_password( data.password, str(current_user.hashed_password) ): return _SchemaResponse(success=False, message="密码错误") - success = PassKey.delete_by_id( - db=None, passkey_id=data.passkey_id, user_id=current_user.id - ) + success = service.delete_by_id(data.passkey_id, current_user.id) if success: logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}") diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index c3344c4ac..1a12d7cac 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -14,8 +14,7 @@ from app.chain.media import MediaChain from app.chain.recommend import RecommendChain from app.schemas.types import MediaSource, MediaType from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo -from app.application.security.access import verify_token -from app.db.models.user import User +from app.adapters.web.security.access import verify_token from app.api.deps import get_current_active_superuser_async from app.chain.listenbrainz import ( LISTENBRAINZ_CHART_RANGES, @@ -114,7 +113,7 @@ async def recognize_music( response_model=_SchemaResponse[_SchemaMusicRecognitionCacheData], ) async def music_recognition_cache( - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """查询可管理的 MusicBrainz 识别缓存。""" cache_items = MusicBrainzChain().cache_items() @@ -137,7 +136,7 @@ async def music_recognition_cache( ) async def delete_music_recognition_cache( cache_key: str, - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """按缓存键删除单条 MusicBrainz 识别缓存。""" deleted_item = MusicBrainzChain().delete_cache(cache_key) @@ -150,7 +149,7 @@ async def delete_music_recognition_cache( "/cache", summary="清空音乐识别缓存", response_model=_SchemaResponse[None] ) async def clear_music_recognition_cache( - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """清空全部 MusicBrainz 识别缓存。""" MusicBrainzChain().clear_cache() diff --git a/app/api/endpoints/notification.py b/app/api/endpoints/notification.py index d4d453595..f23fbcc6a 100644 --- a/app/api/endpoints/notification.py +++ b/app/api/endpoints/notification.py @@ -6,7 +6,6 @@ from app.schemas.common import ManageRequest as _SchemaManageRequest from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.notification import NotificationChain -from app.db.models import User from app.api.deps import get_current_active_superuser router = ResponseAPIRouter() @@ -19,7 +18,7 @@ router = ResponseAPIRouter() ) def manage_channel( request: _SchemaManageRequest, - _: User = Depends(get_current_active_superuser), + _: object = Depends(get_current_active_superuser), ): """ 通知渠道统一管理入口 diff --git a/app/api/endpoints/openai.py b/app/api/endpoints/openai.py index f8a43b07d..ea6407ba2 100644 --- a/app/api/endpoints/openai.py +++ b/app/api/endpoints/openai.py @@ -32,7 +32,7 @@ from app.agent.runtime_loader import ( ) from app.agent.contracts import ReplyMode from app.runtime.config import settings -from app.application.security.access import openai_bearer_scheme +from app.adapters.web.security.access import openai_bearer_scheme from app.schemas.types import NotificationChannel OPENAI_ERROR_RESPONSES = { diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index d65bb6a33..8b9f0a3b7 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -35,14 +35,18 @@ from app.application.commands import init_commands from app.application.scheduling import remove_plugin_job, update_plugin_job from app.runtime.cache import async_fresh from app.runtime.config import settings -from app.runtime.extensions.plugin_manager import PluginManager -from app.application.security.access import ( +from app.application.plugin.runtime import get_plugin_manager as PluginManager +from app.runtime.extensions.plugin.contracts import ( + PluginDashboardError, + PluginNotFoundError, +) +from app.adapters.web.security.access import ( resource_token_cookie, verify_resource_token, verify_token, ) -from app.db.models import User -from app.db.oper.systemconfig import SystemConfigOper +from app.api.principal import ApiPrincipal +from app.application.configuration import get_configured_system_config from app.api.deps import ( get_current_active_superuser, get_current_active_superuser_async, @@ -240,7 +244,7 @@ async def _get_plugin_history_detail( @router.get("/", summary="所有插件", response_model=List[_SchemaPlugin]) async def all_plugins( - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), state: Optional[str] = "all", force: bool = False, ) -> List[_SchemaPlugin]: @@ -298,17 +302,17 @@ async def all_plugins( @router.get("/installed", summary="已安装插件", response_model=List[str]) -async def installed(_: User = Depends(get_current_active_superuser_async)) -> Any: +async def installed(_: ApiPrincipal = Depends(get_current_active_superuser_async)) -> Any: """ 查询用户已安装插件清单 """ - return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + return get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] @router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=_SchemaPlugin) async def plugin_history( plugin_id: str, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), force: bool = True, ) -> _SchemaPlugin: """ @@ -330,7 +334,7 @@ async def plugin_history( ) async def plugin_releases( plugin_id: str, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), repo_url: Optional[str] = "", force: bool = False, ) -> dict: @@ -405,7 +409,7 @@ async def statistic(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: ) async def plugin_ratings( plugin_ids: Optional[str] = None, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> Dict[str, _SchemaPluginRating]: """ 批量查询插件平均分、评分人数和当前安装实例评分。 @@ -425,7 +429,7 @@ async def plugin_ratings( ) async def plugin_rating( plugin_id: str, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> _SchemaPluginRating: """ 查询单个插件平均分、评分人数和当前安装实例评分。 @@ -442,12 +446,12 @@ async def plugin_rating( async def rate_plugin( plugin_id: str, payload: _SchemaPluginRatingRequest, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """ 为已安装插件新增或更新当前安装实例评分。 """ - installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + installed_plugins = get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] if plugin_id not in installed_plugins: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -467,7 +471,7 @@ async def rate_plugin( "/reload/{plugin_id}", summary="重新加载插件", response_model=_SchemaResponse[None] ) def reload_plugin( - plugin_id: str, _: User = Depends(get_current_active_superuser) + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> Any: """ 重新加载插件 @@ -485,7 +489,7 @@ async def install( repo_url: Optional[str] = "", release_version: Optional[str] = None, force: Optional[bool] = False, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> Any: """ 安装插件 @@ -495,7 +499,7 @@ async def install( async def save_installed_plugins(plugin_ids: List[str]) -> object: """保存安装用例确认后的插件列表。""" - return await SystemConfigOper().async_set( + return await get_configured_system_config().async_set( SystemConfigKey.UserInstalledPlugins, plugin_ids, ) @@ -523,7 +527,7 @@ async def install( return await run_in_threadpool(register_plugin, target_id) command = PluginInstallCommand( - installed_plugins_reader=lambda: SystemConfigOper().get( + installed_plugins_reader=lambda: get_configured_system_config().get( SystemConfigKey.UserInstalledPlugins ) or [], installed_plugins_writer=save_installed_plugins, @@ -585,7 +589,7 @@ def plugin_sidebar_nav(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: response_model=_SchemaJsonObject, ) def plugin_form( - plugin_id: str, _: User = Depends(get_current_active_superuser) + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> dict: """ 根据插件ID获取插件配置表单或Vue组件URL @@ -621,7 +625,7 @@ def plugin_form( response_model=_SchemaJsonObject, ) def plugin_page( - plugin_id: str, _: User = Depends(get_current_active_superuser) + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> dict: """ 根据插件ID获取插件数据页面 @@ -649,7 +653,7 @@ def plugin_page( response_model=List[_SchemaPluginDashboardMetaItem], ) def plugin_dashboard_meta( - _: User = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_superuser), ) -> List[dict]: """ 获取所有插件仪表板元信息 @@ -662,19 +666,24 @@ def plugin_dashboard_by_key( plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, - _: User = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_superuser), ) -> Optional[_SchemaPluginDashboard]: """ 根据插件ID获取插件仪表板 """ - return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent) + try: + return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent) + except PluginNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except PluginDashboardError as error: + raise HTTPException(status_code=500, detail=str(error)) from error @router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置") def plugin_dashboard( plugin_id: str, user_agent: Annotated[str | None, Header()] = None, - _: User = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_superuser), ) -> Optional[_SchemaPluginDashboard]: """ 根据插件ID获取插件仪表板 @@ -687,7 +696,7 @@ def plugin_dashboard( ) def reset_plugin( plugin_id: str, - _: User = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_superuser), command: PluginConfigCommand = Depends(get_plugin_config_command), ) -> Any: """ @@ -798,13 +807,13 @@ async def plugin_static_file( response_model=_SchemaPluginFoldersData, ) async def get_plugin_folders( - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> dict: """ 获取插件文件夹分组配置 """ try: - result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} + result = get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} return result except Exception as e: logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}") @@ -813,13 +822,13 @@ async def get_plugin_folders( @router.post("/folders", summary="保存插件文件夹配置", response_model=_SchemaResponse[None]) async def save_plugin_folders( - folders: dict, _: User = Depends(get_current_active_superuser_async) + folders: dict, _: ApiPrincipal = Depends(get_current_active_superuser_async) ) -> Any: """ 保存插件文件夹分组配置 """ try: - SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) + get_configured_system_config().set(SystemConfigKey.PluginFolders, folders) return _SchemaResponse(success=True) except Exception as e: logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}") @@ -830,15 +839,15 @@ async def save_plugin_folders( "/folders/{folder_name}", summary="创建插件文件夹", response_model=_SchemaResponse[None] ) async def create_plugin_folder( - folder_name: str, _: User = Depends(get_current_active_superuser_async) + folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async) ) -> Any: """ 创建新的插件文件夹 """ - folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} + folders = get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} if folder_name not in folders: folders[folder_name] = [] - SystemConfigOper().set(SystemConfigKey.PluginFolders, folders) + get_configured_system_config().set(SystemConfigKey.PluginFolders, folders) return _SchemaResponse( success=True, message=f"文件夹 '{folder_name}' 创建成功" ) @@ -850,15 +859,15 @@ async def create_plugin_folder( "/folders/{folder_name}", summary="删除插件文件夹", response_model=_SchemaResponse[None] ) async def delete_plugin_folder( - folder_name: str, _: User = Depends(get_current_active_superuser_async) + folder_name: str, _: ApiPrincipal = Depends(get_current_active_superuser_async) ) -> Any: """ 删除插件文件夹 """ - folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} + folders = get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} if folder_name in folders: del folders[folder_name] - await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) + await get_configured_system_config().async_set(SystemConfigKey.PluginFolders, folders) return _SchemaResponse( success=True, message=f"文件夹 '{folder_name}' 删除成功" ) @@ -874,14 +883,14 @@ async def delete_plugin_folder( async def update_folder_plugins( folder_name: str, plugin_ids: List[str], - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> Any: """ 更新指定文件夹中的插件列表 """ - folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {} + folders = get_configured_system_config().get(SystemConfigKey.PluginFolders) or {} folders[folder_name] = plugin_ids - await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders) + await get_configured_system_config().async_set(SystemConfigKey.PluginFolders, folders) return _SchemaResponse( success=True, message=f"文件夹 '{folder_name}' 中的插件已更新" ) @@ -891,7 +900,7 @@ async def update_folder_plugins( "/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None] ) def clone_plugin( - plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser) + plugin_id: str, clone_data: dict, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> Any: """ 创建插件分身 @@ -925,7 +934,7 @@ def clone_plugin( response_model=_SchemaJsonObject, ) async def plugin_config( - plugin_id: str, _: User = Depends(get_current_active_superuser_async) + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser_async) ) -> dict: """ 根据插件ID获取插件配置信息 @@ -937,7 +946,7 @@ async def plugin_config( def set_plugin_config( plugin_id: str, conf: dict, - _: User = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_superuser), command: PluginConfigCommand = Depends(get_plugin_config_command), ) -> Any: """ @@ -949,12 +958,12 @@ def set_plugin_config( @router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None]) def uninstall_plugin( - plugin_id: str, _: User = Depends(get_current_active_superuser) + plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> Any: """ 卸载插件 """ - config_oper = SystemConfigOper() + config_oper = get_configured_system_config() # 删除已安装信息 install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] for plugin in install_plugins: @@ -995,7 +1004,7 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): :param clone_plugin_id: 分身插件ID """ try: - config_oper = SystemConfigOper() + config_oper = get_configured_system_config() # 获取插件文件夹配置 folders = config_oper.get(SystemConfigKey.PluginFolders) or {} diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index 64ac4d1a6..686ca6f11 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -9,7 +9,7 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.recommend import RecommendChain from app.runtime.events import eventmanager -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token from app.schemas.exception import TMDbException from app.schemas.event import RecommendSourceEventData from app.schemas.types import ChainEventType diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index d2011f883..6073d4d42 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -16,7 +16,7 @@ from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.workflow import Context as _SchemaContext from app.api.response import ResponseAPIRouter from app.chain.search import SearchChain -from app.application.security.access import verify_resource_token, verify_token +from app.adapters.web.security.access import verify_resource_token, verify_token from app.runtime.localization import LocaleHelper from app.runtime.log import logger from app.schemas.types import MediaSource, MediaType diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index e734e0939..996734a15 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -1,8 +1,6 @@ from typing import List, Any, Dict, Optional from fastapi import Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session from starlette.background import BackgroundTasks from app.schemas.common import JsonObject as _SchemaJsonObject @@ -19,32 +17,28 @@ from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.workflow import Site as _SchemaSite from app.api.response import ResponseAPIRouter from app.application.site.mutation import SiteMutationCommand +from app.application.site.query import SiteQueryService from app.api.endpoints.plugin import register_plugin_api from app.chain.site import SiteChain from app.chain.torrents import TorrentsChain from app.command import Command -from app.runtime.events import eventmanager -from app.runtime.extensions.plugin_manager import PluginManager -from app.application.security.access import verify_token -from app.db import get_db, get_async_db -from app.db.models import User -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.site import SiteOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.plugin.runtime import get_plugin_manager as PluginManager +from app.adapters.web.security.access import verify_token +from app.api.principal import ApiPrincipal +from app.application.configuration import get_configured_system_config from app.api.deps import ( get_current_active_manage_user, get_current_active_manage_user_async, get_current_active_superuser, get_current_active_superuser_async, get_site_mutation_command, + get_site_query_service, + get_site_sync_query_service, ) from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger -from app.scheduler import Scheduler -from app.schemas.types import SystemConfigKey, EventType, MediaType +from app.application.scheduling import Scheduler +from app.schemas.types import SystemConfigKey, MediaType from app.domain import site as site_rules router = ResponseAPIRouter() @@ -88,13 +82,13 @@ def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool: @router.get("/", summary="所有站点", response_model=List[_SchemaSite]) async def read_sites( - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: SiteQueryService = Depends(get_site_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> List[dict]: """ 获取站点列表 """ - return await Site.async_list_order_by_pri(db) + return await query.list_ordered() @router.get( @@ -104,14 +98,14 @@ async def read_sites( ) async def read_sites_by_media_type( media_type: str, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), -) -> List[Site]: + query: SiteQueryService = Depends(get_site_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), +) -> List[_SchemaSite]: """ 获取支持指定媒体类型的已配置启用站点。 :param media_type: Agent 媒体类型名称或中文媒体类型 - :param db: 异步数据库会话 + :param query: 站点查询服务 :return: 按优先级排序的可搜索站点 """ target_media_type = MediaType.from_agent(media_type) @@ -134,7 +128,7 @@ async def read_sites_by_media_type( if domain: supported_domains.add(domain) - sites = await Site.async_list_order_by_pri(db) + sites = await query.list_ordered() return [ site for site in sites @@ -148,7 +142,7 @@ async def add_site( *, site_in: _SchemaSite, command: SiteMutationCommand = Depends(get_site_mutation_command), - _: User = Depends(get_current_active_manage_user_async), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 新增站点 @@ -162,7 +156,7 @@ async def update_site( *, site_in: _SchemaSite, command: SiteMutationCommand = Depends(get_site_mutation_command), - _: User = Depends(get_current_active_manage_user_async), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 更新站点信息 @@ -174,7 +168,7 @@ async def update_site( @router.get("/cookiecloud", summary="CookieCloud同步", response_model=_SchemaResponse[None]) async def cookie_cloud_sync( background_tasks: BackgroundTasks, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> Any: """ 运行CookieCloud同步站点信息 @@ -184,20 +178,20 @@ async def cookie_cloud_sync( @router.get("/reset", summary="重置站点", response_model=_SchemaResponse[None]) -def reset( - db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser) +async def reset( + command: SiteMutationCommand = Depends(get_site_mutation_command), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> Any: """ 清空所有站点数据并重新同步CookieCloud站点信息 """ - Site.reset(db) - SystemConfigOper().set(SystemConfigKey.IndexerSites, []) - SystemConfigOper().set(SystemConfigKey.RssSites, []) + result = await command.reset() + get_configured_system_config().set(SystemConfigKey.IndexerSites, []) + get_configured_system_config().set(SystemConfigKey.RssSites, []) # 启动定时服务 Scheduler().start("cookiecloud", manual=True) # 插件站点删除 - eventmanager.send_event(EventType.SiteDeleted, {"site_id": "*"}) - return _SchemaResponse(success=True, message="站点已重置!") + return _SchemaResponse(success=result.success, message="站点已重置!") @router.post( @@ -206,7 +200,7 @@ def reset( async def update_sites_priority( priorities: List[dict], command: SiteMutationCommand = Depends(get_site_mutation_command), - _: User = Depends(get_current_active_manage_user_async), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 批量更新站点优先级 @@ -220,7 +214,7 @@ def _update_site_cookie( username: str, password: str, code: Optional[str], - db: Session, + query: SiteQueryService, ) -> _SchemaResponse: """ 执行站点 Cookie 与 UA 更新。 @@ -229,10 +223,10 @@ def _update_site_cookie( :param username: 站点登录用户名 :param password: 站点登录密码 :param code: 二步验证码或密钥 - :param db: 数据库会话 + :param query: 站点查询服务 :return: 更新结果 """ - site_info = Site.get(db, site_id) + site_info = query.get_sync(site_id) if not site_info: raise HTTPException( status_code=404, @@ -255,8 +249,8 @@ def _update_site_cookie( def update_cookie_by_body( site_id: int, site_cookie_update: _SchemaSiteCookieUpdate, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + query: SiteQueryService = Depends(get_site_sync_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user), ) -> Any: """ 使用请求体中的用户密码更新站点Cookie @@ -266,7 +260,7 @@ def update_cookie_by_body( username=site_cookie_update.username, password=site_cookie_update.password, code=site_cookie_update.code, - db=db, + query=query, ) @@ -278,8 +272,8 @@ def update_cookie( username: str, password: str, code: Optional[str] = None, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + query: SiteQueryService = Depends(get_site_sync_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user), ) -> Any: """ 使用用户密码更新站点Cookie @@ -289,7 +283,7 @@ def update_cookie( username=username, password=password, code=code, - db=db, + query=query, ) @@ -300,13 +294,13 @@ def update_cookie( ) def refresh_userdata( site_id: int, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + query: SiteQueryService = Depends(get_site_sync_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user), ) -> Any: """ 刷新站点用户数据 """ - site = Site.get(db, site_id) + site = query.get_sync(site_id) if not site: raise HTTPException( status_code=404, @@ -327,16 +321,13 @@ def refresh_userdata( response_model=List[_SchemaSiteUserData], ) async def read_userdata_latest( - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: SiteQueryService = Depends(get_site_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 查询所有站点最新用户数据 """ - user_datas = await SiteUserData.async_get_latest(db) - if not user_datas: - return [] - return [user_data.to_dict() for user_data in user_datas] + return await query.userdata_latest() @router.get( @@ -347,36 +338,34 @@ async def read_userdata_latest( async def read_userdata( site_id: int, workdate: Optional[str] = None, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: SiteQueryService = Depends(get_site_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 查询站点用户数据 """ - site = await Site.async_get(db, site_id) + site = await query.get(site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) - user_datas = await SiteUserData.async_get_by_domain( - db, domain=site.domain, workdate=workdate - ) + user_datas = await query.userdata(site.domain, workdate) if not user_datas: return _SchemaResponse(success=False, data=[]) - return _SchemaResponse(success=True, data=[data.to_dict() for data in user_datas]) + return _SchemaResponse(success=True, data=user_datas) @router.get("/test/{site_id}", summary="连接测试", response_model=_SchemaResponse[None]) def test_site( site_id: int, - db: Session = Depends(get_db), + query: SiteQueryService = Depends(get_site_sync_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 测试站点是否可用 """ - site = Site.get(db, site_id) + site = query.get_sync(site_id) if not site: raise HTTPException( status_code=404, @@ -393,24 +382,22 @@ def test_site( ) async def site_icon( site_id: int, - db: AsyncSession = Depends(get_async_db), + query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取站点图标:base64或者url """ - site = await Site.async_get(db, site_id) + site = await query.get(site_id) if not site: raise HTTPException( status_code=404, detail=f"站点 {site_id} 不存在", ) - icon = await SiteIcon.async_get_by_domain(db, site.domain) + icon = await query.icon(site.domain) if not icon: return _SchemaResponse(success=False, message="站点图标不存在!") - return _SchemaResponse( - success=True, data={"icon": icon.base64 if icon.base64 else icon.url} - ) + return _SchemaResponse(success=True, data=icon.model_dump()) @router.get( @@ -418,13 +405,13 @@ async def site_icon( ) async def site_category( site_id: int, - db: AsyncSession = Depends(get_async_db), + query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取站点分类 """ - site = await Site.async_get(db, site_id) + site = await query.get(site_id) if not site: raise HTTPException( status_code=404, @@ -456,13 +443,13 @@ async def site_resource( mtype: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: SiteQueryService = Depends(get_site_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 浏览站点资源 """ - site = await Site.async_get(db, site_id) + site = await query.get(site_id) if not site: raise HTTPException( status_code=404, @@ -483,14 +470,14 @@ async def site_resource( @router.get("/domain/{site_url}", summary="站点详情", response_model=_SchemaSite) async def read_site_by_domain( site_url: str, - db: AsyncSession = Depends(get_async_db), + query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 通过域名获取站点信息 """ domain = site_rules.extract_domain(site_url) - site = await Site.async_get_by_domain(db, domain) + site = await query.get_by_domain(domain) if not site: raise HTTPException( status_code=404, @@ -506,45 +493,42 @@ async def read_site_by_domain( ) async def read_statistic_by_domain( site_url: str, - db: AsyncSession = Depends(get_async_db), + query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 通过域名获取站点统计信息 """ domain = site_rules.extract_domain(site_url) - sitestatistic = await SiteStatistic.async_get_by_domain(db, domain) - if sitestatistic: - return sitestatistic - return _SchemaSiteStatistic(domain=domain) + return await query.statistic(domain) @router.get( "/statistic", summary="所有站点统计信息", response_model=List[_SchemaSiteStatistic] ) async def read_statistics( - db: AsyncSession = Depends(get_async_db), + query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 获取所有站点统计信息 """ - return await SiteStatistic.async_list(db) + return await query.statistics() @router.get("/rss", summary="所有订阅站点", response_model=List[_SchemaSite]) async def read_rss_sites( - db: AsyncSession = Depends(get_async_db), + query: SiteQueryService = Depends(get_site_query_service), _: _SchemaTokenPayload = Depends(verify_token), ) -> List[dict]: """ 获取站点列表 """ # 选中的rss站点 - selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] + selected_sites = get_configured_system_config().get(SystemConfigKey.RssSites) or [] # 所有站点 - all_site = await Site.async_list_order_by_pri(db) + all_site = await query.list_ordered() if not selected_sites: return all_site @@ -563,7 +547,7 @@ async def read_auth_sites(_: _SchemaTokenPayload = Depends(verify_token)) -> dic @router.post("/auth", summary="用户站点认证", response_model=_SchemaResponse[None]) def auth_site( - auth_info: _SchemaSiteAuth, _: User = Depends(get_current_active_superuser) + auth_info: _SchemaSiteAuth, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> Any: """ 用户站点认证 @@ -571,7 +555,7 @@ def auth_site( if not auth_info or not auth_info.site or not auth_info.params: return _SchemaResponse(success=False, message="请输入认证站点和认证参数") status, msg = SitesHelper().check_user(auth_info.site, auth_info.params) - SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump()) + get_configured_system_config().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump()) # 认证成功后,重新初始化插件 PluginManager().init_config() Scheduler().init_plugin_jobs() @@ -585,12 +569,15 @@ def auth_site( summary="获取站点域名到名称的映射", response_model=_SchemaResponse[_SchemaSiteMappingData], ) -async def site_mapping(_: User = Depends(get_current_active_superuser_async)): +async def site_mapping( + query: SiteQueryService = Depends(get_site_sync_query_service), + _: ApiPrincipal = Depends(get_current_active_superuser_async), +): """ 获取站点域名到名称的映射关系 """ try: - sites = await SiteOper().async_list() + sites = query.list_sync() mapping = {} for site in sites: mapping[site.domain] = site.name @@ -604,7 +591,7 @@ async def site_mapping(_: User = Depends(get_current_active_superuser_async)): summary="获取支持的站点列表", response_model=_SchemaJsonObject, ) -async def support_sites(_: User = Depends(get_current_active_superuser_async)): +async def support_sites(_: ApiPrincipal = Depends(get_current_active_superuser_async)): """ 获取支持的站点列表 """ @@ -614,13 +601,13 @@ async def support_sites(_: User = Depends(get_current_active_superuser_async)): @router.get("/{site_id}", summary="站点详情", response_model=_SchemaSite) async def read_site( site_id: int, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: SiteQueryService = Depends(get_site_query_service), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 通过ID获取站点信息 """ - site = await Site.async_get(db, site_id) + site = await query.get(site_id) if not site: raise HTTPException( status_code=404, @@ -633,7 +620,7 @@ async def read_site( async def delete_site( site_id: int, command: SiteMutationCommand = Depends(get_site_mutation_command), - _: User = Depends(get_current_active_manage_user_async), + _: ApiPrincipal = Depends(get_current_active_manage_user_async), ) -> Any: """ 删除站点 diff --git a/app/api/endpoints/storage.py b/app/api/endpoints/storage.py index a4f9ace98..8f652684c 100644 --- a/app/api/endpoints/storage.py +++ b/app/api/endpoints/storage.py @@ -15,7 +15,7 @@ from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.runtime.config import settings -from app.db.models import User +from app.api.principal import ApiPrincipal from app.api.deps import ( get_current_active_manage_user, get_current_active_superuser, @@ -31,7 +31,7 @@ router = ResponseAPIRouter() "/manage", summary="网盘存储统一管理", response_model=_SchemaResponse[Dict[str, Any]] ) def manage( - request: _SchemaManageRequest, _: User = Depends(get_current_active_superuser) + request: _SchemaManageRequest, _: ApiPrincipal = Depends(get_current_active_superuser) ) -> Any: """ 网盘存储统一管理入口 @@ -56,7 +56,7 @@ def list_files( fileitem: _SchemaFileItem, sort: Optional[str] = "updated_at", keyword: Optional[str] = None, - _: User = Depends(get_current_active_manage_user), + _: ApiPrincipal = Depends(get_current_active_manage_user), ) -> Any: """ 查询当前目录下所有目录和文件 @@ -82,7 +82,7 @@ def list_files( def mkdir( fileitem: _SchemaFileItem, name: str, - _: User = Depends(get_current_active_manage_user), + _: ApiPrincipal = Depends(get_current_active_manage_user), ) -> Any: """ 创建目录 @@ -100,7 +100,7 @@ def mkdir( @router.post("/delete", summary="删除文件或目录", response_model=_SchemaResponse[None]) def delete( - fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user) + fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user) ) -> Any: """ 删除文件或目录 @@ -131,7 +131,7 @@ def delete( }, ) def download( - fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user) + fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user) ) -> Any: """ 下载文件或目录 @@ -160,7 +160,7 @@ def download( }, ) def image( - fileitem: _SchemaFileItem, _: User = Depends(get_current_active_manage_user) + fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user) ) -> Any: """ 下载文件或目录 @@ -179,7 +179,7 @@ def rename( fileitem: _SchemaFileItem, new_name: str, recursive: Optional[bool] = False, - _: User = Depends(get_current_active_manage_user), + _: ApiPrincipal = Depends(get_current_active_manage_user), ) -> Any: """ 重命名文件或目录 diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 4c2104a43..8c3cfb0f5 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -2,8 +2,6 @@ from typing import List, Any, Annotated, Optional import cn2an from fastapi import Request, BackgroundTasks, Depends, HTTPException, Header -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session from app.schemas.common import IdData as _SchemaIdData from app.schemas.response import Response as _SchemaResponse @@ -19,7 +17,7 @@ from app.runtime.config import settings from app.domain.context import MediaInfo from app.runtime.events import eventmanager from app.domain.metainfo import MetaInfo -from app.application.security.access import verify_token, verify_apitoken +from app.adapters.web.security.access import verify_token, verify_apitoken from app.application.subscription.delete import ( DeleteSubscribeCommand, SubscribeDeletionActor, @@ -31,20 +29,25 @@ from app.application.subscription.search import ( SearchSubscriptionsCommand, SubscribeSearchActor, ) -from app.db import get_async_db, get_db -from app.db.models.subscribe import Subscribe -from app.db.models.subscribehistory import SubscribeHistory -from app.db.models.user import User -from app.db.oper.systemconfig import SystemConfigOper +from app.api.principal import ApiPrincipal +from app.application.subscription.query import SubscriptionQueryService +from app.application.subscription.mutation import ( + SubscriptionActor, + SubscriptionMutationService, +) +from app.application.configuration import get_configured_system_config from app.api.deps import ( get_current_active_user, get_current_active_user_async, get_delete_subscribe_command, get_delete_subscriptions_by_identity_command, get_search_subscriptions_command, + get_subscription_query_service, + get_subscription_mutation_service, + get_subscription_sync_mutation_service, ) from app.adapters.external.server import MoviePilotServerHelper -from app.scheduler import Scheduler +from app.application.scheduling import Scheduler from app.schemas.event import SubscribeModifiedEventData from app.schemas.types import ( MUSIC_ENTITY_ALBUM, @@ -82,16 +85,15 @@ def start_subscribe_add( ) -def build_subscribe_event_payload(subscribe: Subscribe) -> dict: +def build_subscribe_event_payload(subscribe: Any) -> dict: """ 从 ORM 已加载字段构造订阅事件快照,避免异步接口里属性懒加载触发隐式 IO。 """ - values = subscribe.__dict__ - return {column.name: values.get(column.name) for column in subscribe.__table__.columns} + return subscribe.to_dict() def can_access_subscribe( - subscribe: Subscribe | SubscribeHistory | None, current_user: User + subscribe: Any, current_user: ApiPrincipal ) -> bool: """ 判断当前用户是否可访问订阅及其历史记录。 @@ -107,33 +109,9 @@ def can_access_subscribe( return bool(username) and username == current_user.name -async def get_accessible_subscribe( - db: AsyncSession, subscribe_id: int, current_user: User -) -> Subscribe | None: - """ - 按订阅 ID 读取当前用户可访问的订阅行。 - """ - subscribe = await Subscribe.async_get(db, subscribe_id) - if can_access_subscribe(subscribe, current_user): - return subscribe - return None - - -def get_accessible_subscribe_sync( - db: Session, subscribe_id: int, current_user: User -) -> Subscribe | None: - """ - 同步读取当前用户可访问的订阅行。 - """ - subscribe = Subscribe.get(db, subscribe_id) - if can_access_subscribe(subscribe, current_user): - return subscribe - return None - - def select_accessible_subscribe( - subscribes: List[Subscribe], current_user: User -) -> Subscribe | None: + subscribes: List[Any], current_user: ApiPrincipal +) -> Any: """ 从候选订阅中选择当前用户可访问的第一条记录。 """ @@ -144,7 +122,7 @@ def select_accessible_subscribe( def matches_subscribe_music_type( - subscribe: Subscribe, + subscribe: Any, music_type: Optional[str], ) -> bool: """匹配订阅音乐实体,并把迁移前未标注类型的历史记录兼容为单曲。""" @@ -155,54 +133,30 @@ def matches_subscribe_music_type( or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None) -async def list_subscribes_by_media_identity( - db: AsyncSession, - media_source: MediaSource, - media_id: str, - season: Optional[int] = None, - music_type: Optional[str] = None, -) -> List[Subscribe]: - """按媒体来源、原生 ID 及音乐实体查询订阅。""" - subscribes = list(await Subscribe.async_list_by_media_identity( - db, - media_source=media_source, - media_id=media_id, - music_type=music_type, - )) - unique_subscribes = { - subscribe.id: subscribe - for subscribe in subscribes - if matches_subscribe_music_type(subscribe, music_type) - } - if season is not None: - return [ - subscribe for subscribe in unique_subscribes.values() - if subscribe.season == season - ] - return list(unique_subscribes.values()) - - @router.get("/", summary="查询所有订阅", response_model=List[_SchemaSubscribe]) async def read_subscribes( - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + query: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 查询所有订阅 """ if not current_user.is_superuser: - return await Subscribe.async_list_by_username(db, current_user.name) - return await Subscribe.async_list(db) + return await query.list_public(current_user.name) + return await query.list_public() @router.get( "/list", summary="查询所有订阅(API_TOKEN)", response_model=List[_SchemaSubscribe] ) -async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: +async def list_subscribes( + query: SubscriptionQueryService = Depends(get_subscription_query_service), + _: Annotated[str, Depends(verify_apitoken)] = None, +) -> Any: """ 查询所有订阅 API_TOKEN认证(?token=xxx) """ - return await Subscribe.async_list() + return await query.list_public() @router.post( @@ -213,7 +167,7 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any: async def create_subscribe( *, subscribe_in: _SchemaSubscribe, - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> _SchemaResponse: """ 新增订阅 @@ -274,13 +228,17 @@ async def create_subscribe( async def update_subscribe( *, subscribe_in: _SchemaSubscribe, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 更新订阅信息 """ - subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user) + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + subscribe = await mutation.get_accessible(subscribe_in.id, actor) if not subscribe: return _SchemaResponse(success=False, message="订阅不存在") old_subscribe_dict = subscribe.to_dict() @@ -326,16 +284,21 @@ async def update_subscribe( if total_episode_updated and subscribe_in.total_episode != subscribe.total_episode: subscribe_dict["manual_total_episode"] = 1 # 更新到数据库 - await subscribe.async_update(db, subscribe_dict) - # 重新获取更新后的订阅数据 - updated_subscribe = await Subscribe.async_get(db, subscribe_in.id) + change = await mutation.update( + subscribe_in.id, + subscribe_dict, + actor, + existing=subscribe, + ) + if not change: + return _SchemaResponse(success=False, message="订阅不存在") # 发送订阅调整事件 await eventmanager.async_send_event( EventType.SubscribeModified, SubscribeModifiedEventData( subscribe_id=subscribe_in.id, - old_subscribe_info=old_subscribe_dict, - subscribe_info=updated_subscribe.to_dict() if updated_subscribe else {}, + old_subscribe_info=change.old, + subscribe_info=change.new, scene="update", ).to_dict(), ) @@ -346,29 +309,29 @@ async def update_subscribe( async def update_subscribe_status( subid: int, state: str, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 更新订阅状态 """ - subscribe = await get_accessible_subscribe(db, subid, current_user) - if not subscribe: - return _SchemaResponse(success=False, message="订阅不存在") valid_states = ["R", "P", "S"] if state not in valid_states: return _SchemaResponse(success=False, message="无效的订阅状态") - old_subscribe_dict = subscribe.to_dict() - await subscribe.async_update(db, {"state": state}) - # 重新获取更新后的订阅数据 - updated_subscribe = await Subscribe.async_get(db, subid) + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + change = await mutation.update_status(subid, state, actor) + if not change: + return _SchemaResponse(success=False, message="订阅不存在") # 发送订阅调整事件 await eventmanager.async_send_event( EventType.SubscribeModified, SubscribeModifiedEventData( subscribe_id=subid, - old_subscribe_info=old_subscribe_dict, - subscribe_info=updated_subscribe.to_dict() if updated_subscribe else {}, + old_subscribe_info=change.old, + subscribe_info=change.new, scene="status", ).to_dict(), ) @@ -382,22 +345,22 @@ async def subscribe_media_identity( season: Optional[int] = None, title: Optional[str] = None, music_type: Optional[str] = None, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + query: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 根据媒体来源和原生 ID 查询订阅。 """ - subscribes = await list_subscribes_by_media_identity( - db, media_source, media_id, season, music_type - ) + subscribes = await query.list_by_media_identity(media_source, media_id, music_type) + if season is not None: + subscribes = [subscribe for subscribe in subscribes if subscribe.season == season] result = select_accessible_subscribe(subscribes, current_user) - return result if result else Subscribe() + return result if result else _SchemaSubscribe() @router.get("/refresh", summary="刷新订阅", response_model=_SchemaResponse[None]) def refresh_subscribes( - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: """ 刷新所有订阅 @@ -411,44 +374,24 @@ def refresh_subscribes( @router.get("/reset/{subid}", summary="重置订阅", response_model=_SchemaResponse[None]) async def reset_subscribes( subid: int, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 重置订阅 """ - subscribe = await get_accessible_subscribe(db, subid, current_user) - if subscribe: - # 在更新之前获取旧数据 - old_subscribe_dict = subscribe.to_dict() - # 更新订阅 - await subscribe.async_update( - db, - { - "note": [], - "lack_episode": subscribe.total_episode, - "current_priority": None, - "current_audio_format": None, - "current_bitrate": None, - "current_bit_depth": None, - "current_sample_rate": None, - "episode_priority": {}, - # 重置代表放弃手动总集数,后续订阅检查重新按 TMDB 集数更新。 - "manual_total_episode": 0, - "state": "R", - }, - ) - # 重新获取更新后的订阅数据 - updated_subscribe = await Subscribe.async_get(db, subid) - # 发送订阅调整事件 + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + change = await mutation.reset(subid, actor) + if change: await eventmanager.async_send_event( EventType.SubscribeModified, SubscribeModifiedEventData( subscribe_id=subid, - old_subscribe_info=old_subscribe_dict, - subscribe_info=updated_subscribe.to_dict() - if updated_subscribe - else {}, + old_subscribe_info=change.old, + subscribe_info=change.new, scene="reset", ).to_dict(), ) @@ -458,7 +401,7 @@ async def reset_subscribes( @router.get("/check", summary="刷新订阅 TMDB 信息", response_model=_SchemaResponse[None]) def check_subscribes( - current_user: User = Depends(get_current_active_user), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: """ 刷新订阅 TMDB 信息 @@ -472,7 +415,7 @@ def check_subscribes( @router.get("/search", summary="搜索所有订阅", response_model=_SchemaResponse[None]) async def search_subscribes( command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 搜索所有订阅 @@ -492,7 +435,7 @@ async def search_subscribes( async def search_subscribe( subscribe_id: int, command: SearchSubscriptionsCommand = Depends(get_search_subscriptions_command), - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 根据订阅编号搜索订阅 @@ -518,7 +461,7 @@ async def delete_subscribe_by_media_identity( command: DeleteSubscriptionsByIdentityCommand = Depends( get_delete_subscriptions_by_identity_command ), - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 根据任意媒体数据源 ID 删除订阅。 @@ -616,28 +559,18 @@ async def subscribe_history( mtype: str, page: Optional[int] = 1, count: Optional[int] = 30, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + query: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 查询电影、电视剧或音乐订阅历史 """ - if current_user.is_superuser: - histories = await SubscribeHistory.async_list_by_type( - db, mtype=mtype, page=page, count=count - ) - else: - histories = await SubscribeHistory.async_list_by_type_and_username( - db, mtype=mtype, username=current_user.name, page=page, count=count - ) - result = [] - for history in histories: - history_item = _SchemaSubscribe.model_validate(history, from_attributes=True) - if history_item.type == MediaType.TV.value: - history_item.total_episode = 0 - history_item.lack_episode = 0 - result.append(history_item) - return result + return await query.list_history( + mtype, + page=page, + count=count, + username=None if current_user.is_superuser else current_user.name, + ) @router.delete( @@ -645,15 +578,17 @@ async def subscribe_history( ) async def delete_subscribe_history( history_id: int, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 删除订阅历史 """ - history = await SubscribeHistory.async_get(db, history_id) - if can_access_subscribe(history, current_user): - await SubscribeHistory.async_delete(db, history_id) + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + await mutation.delete_history(history_id, actor) return _SchemaResponse(success=True) @@ -721,15 +656,15 @@ async def popular_subscribes( ) async def user_subscribes( username: str, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + query: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 查询用户订阅 """ if not current_user.is_superuser and username != current_user.name: return [] - return await Subscribe.async_list_by_username(db, username) + return await query.list_public(username) @router.get( @@ -739,13 +674,17 @@ async def user_subscribes( ) def subscribe_files( subscribe_id: int, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_active_user), + mutation: SubscriptionMutationService = Depends(get_subscription_sync_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user), ) -> Any: """ 订阅相关文件信息 """ - subscribe = get_accessible_subscribe_sync(db, subscribe_id, current_user) + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + subscribe = mutation.get_accessible_sync(subscribe_id, actor) if subscribe: return SubscribeChain().subscribe_files_info(subscribe) return _SchemaSubscrbieInfo() @@ -754,13 +693,17 @@ def subscribe_files( @router.post("/share", summary="分享订阅", response_model=_SchemaResponse[None]) async def subscribe_share( sub: _SchemaSubscribeShare, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + mutation: SubscriptionMutationService = Depends(get_subscription_mutation_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 分享订阅 """ - subscribe = await get_accessible_subscribe(db, sub.subscribe_id, current_user) + actor = SubscriptionActor( + name=current_user.name, + is_superuser=current_user.is_superuser, + ) + subscribe = await mutation.get_accessible(sub.subscribe_id, actor) if not subscribe: return _SchemaResponse(success=False, message="订阅不存在") state, errmsg = await MoviePilotServerHelper.async_sub_share( @@ -786,7 +729,7 @@ async def subscribe_share_delete( @router.post("/fork", summary="复用订阅", response_model=_SchemaResponse[None]) async def subscribe_fork( sub: _SchemaSubscribeShare, - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 复用订阅 @@ -809,7 +752,7 @@ async def followed_subscribers(_: _SchemaTokenPayload = Depends(verify_token)) - """ 查询已Follow的订阅分享人 """ - return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] + return get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or [] @router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None]) @@ -819,10 +762,10 @@ async def follow_subscriber( """ Follow订阅分享人 """ - subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] + subscribers = get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid not in subscribers: subscribers.append(share_uid) - await SystemConfigOper().async_set( + await get_configured_system_config().async_set( SystemConfigKey.FollowSubscribers, subscribers ) return _SchemaResponse(success=True) @@ -837,10 +780,10 @@ async def unfollow_subscriber( """ 取消Follow订阅分享人 """ - subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or [] + subscribers = get_configured_system_config().get(SystemConfigKey.FollowSubscribers) or [] if share_uid and share_uid in subscribers: subscribers.remove(share_uid) - await SystemConfigOper().async_set( + await get_configured_system_config().async_set( SystemConfigKey.FollowSubscribers, subscribers ) return _SchemaResponse(success=True) @@ -891,23 +834,27 @@ async def subscribe_share_statistics( @router.get("/{subscribe_id}", summary="订阅详情", response_model=_SchemaSubscribe) async def read_subscribe( subscribe_id: int, - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_user_async), + query: SubscriptionQueryService = Depends(get_subscription_query_service), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 根据订阅编号查询订阅信息 """ if not subscribe_id: - return Subscribe() - subscribe = await get_accessible_subscribe(db, subscribe_id, current_user) - return subscribe if subscribe else Subscribe() + return _SchemaSubscribe() + subscribe = await query.get_public(subscribe_id) + return ( + subscribe + if subscribe and can_access_subscribe(subscribe, current_user) + else _SchemaSubscribe() + ) @router.delete("/{subscribe_id}", summary="删除订阅", response_model=_SchemaResponse[None]) async def delete_subscribe( subscribe_id: int, command: DeleteSubscribeCommand = Depends(get_delete_subscribe_command), - current_user: User = Depends(get_current_active_user_async), + current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ 删除订阅信息 diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 760d582de..80be3ec20 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -38,10 +38,10 @@ from app.chain.system import SystemChain from app.runtime.config import global_vars, settings from app.runtime.events import eventmanager from app.domain.metainfo import MetaInfo -from app.runtime.extensions.module_manager import ModuleManager -from app.application.security.access import verify_apitoken, verify_resource_token, verify_token -from app.db.models import User -from app.db.oper.systemconfig import SystemConfigOper +from app.application.module import ModuleManager +from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token +from app.api.principal import ApiPrincipal +from app.application.configuration import get_configured_system_config from app.api.deps import get_current_active_superuser, get_current_active_superuser_async, get_current_active_user_async from app.application.image import ImageHelper from app.runtime.localization import LocaleHelper @@ -57,7 +57,7 @@ from app.application.rules import RuleHelper from app.adapters.external.server import MoviePilotServerHelper from app.runtime.state import SystemHelper from app.runtime.log import logger -from app.scheduler import Scheduler +from app.application.scheduling import Scheduler from app.schemas.event import ConfigChangeEventData from app.schemas.types import SystemConfigKey, EventType from app.foundation.crypto import HashUtils @@ -704,7 +704,7 @@ def get_global_setting(token: str): summary="查询用户相关系统设置", response_model=_SchemaResponse[_SchemaJsonObject], ) -async def get_user_global_setting(_: User = Depends(get_current_active_user_async)): +async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_user_async)): """ 查询用户相关系统设置(登录后获取) 包含业务功能相关的配置和用户权限信息 @@ -745,7 +745,7 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn response_model=_SchemaResponse[_SchemaJsonObject], ) async def get_env_setting( - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """ 查询系统环境变量,包括当前版本号(仅管理员) @@ -769,7 +769,7 @@ async def get_env_setting( summary="查询安装版本统计报表", response_model=_SchemaResponse[_SchemaJsonObject], ) -async def usage_statistic(_: User = Depends(get_current_active_user_async)): +async def usage_statistic(_: ApiPrincipal = Depends(get_current_active_user_async)): """ 查询安装版本统计报表 """ @@ -777,7 +777,7 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)): @router.get("/ping", summary="服务存活检测", response_model=_SchemaResponse[None]) -async def ping(_: User = Depends(get_current_active_user_async)) -> _SchemaResponse: +async def ping(_: ApiPrincipal = Depends(get_current_active_user_async)) -> _SchemaResponse: """ 检测服务是否可用 """ @@ -790,7 +790,7 @@ async def ping(_: User = Depends(get_current_active_user_async)) -> _SchemaRespo response_model=_SchemaResponse[_SchemaSystemEnvironmentUpdateData], ) async def set_env_setting( - env: dict, _: User = Depends(get_current_active_superuser_async) + env: dict, _: ApiPrincipal = Depends(get_current_active_superuser_async) ): """ 更新系统环境变量(仅管理员) @@ -870,7 +870,7 @@ async def get_progress( response_model=_SchemaResponse[_SchemaValueData], ) async def get_public_setting( - key: str, _: User = Depends(get_current_active_user_async) + key: str, _: ApiPrincipal = Depends(get_current_active_user_async) ) -> _SchemaResponse: """ 查询普通用户可读取的非敏感系统设置 @@ -879,7 +879,7 @@ async def get_public_setting( return _SchemaResponse(success=True, data={"value": getattr(settings, key)}) if key not in _PUBLIC_SYSTEM_CONFIG_KEYS: raise HTTPException(status_code=404, detail="配置项不存在") - value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key]) + value = get_configured_system_config().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key]) return _SchemaResponse(success=True, data={"value": value}) @@ -890,7 +890,7 @@ async def get_public_setting( ) async def sync_plugin_market_from_wiki( request: Optional[_SchemaPluginMarketSyncRequest] = Body(default=None), - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """ 从 Wiki 插件文档同步插件市场仓库地址。 @@ -956,7 +956,7 @@ async def sync_plugin_market_from_wiki( response_model=_SchemaResponse[_SchemaValueData], ) async def get_setting( - key: str, _: User = Depends(get_current_active_superuser_async) + key: str, _: ApiPrincipal = Depends(get_current_active_superuser_async) ) -> _SchemaResponse: """ 查询系统设置(仅管理员) @@ -964,7 +964,7 @@ async def get_setting( if hasattr(settings, key): value = getattr(settings, key) else: - value = SystemConfigOper().get(key) + value = get_configured_system_config().get(key) return _SchemaResponse(success=True, data={"value": value}) @@ -972,7 +972,7 @@ async def get_setting( async def set_setting( key: str, value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, - _: User = Depends(get_current_active_superuser_async), + _: ApiPrincipal = Depends(get_current_active_superuser_async), ): """ 更新系统设置(仅管理员) @@ -992,7 +992,7 @@ async def set_setting( if isinstance(value, list): value = list(filter(None, value)) value = value if value else None - success = await SystemConfigOper().async_set(key, value) + success = await get_configured_system_config().async_set(key, value) if success: # 发送配置变更事件 await eventmanager.async_send_event( @@ -1446,7 +1446,7 @@ def moduletest(moduleid: str, _: _SchemaTokenPayload = Depends(verify_token)): @router.get("/restart", summary="重启系统", response_model=_SchemaResponse[None]) -def restart_system(_: User = Depends(get_current_active_superuser)): +def restart_system(_: ApiPrincipal = Depends(get_current_active_superuser)): """ 重启系统(仅管理员) """ @@ -1459,7 +1459,7 @@ def restart_system(_: User = Depends(get_current_active_superuser)): @router.post("/upgrade", summary="升级并重启系统", response_model=_SchemaResponse[None]) def upgrade_system( mode: Annotated[str | None, Body()] = None, - _: User = Depends(get_current_active_superuser), + _: ApiPrincipal = Depends(get_current_active_superuser), ): """ 触发系统升级并重启(仅管理员) @@ -1475,7 +1475,7 @@ def upgrade_system( @router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None]) -def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)): +def run_scheduler(jobid: str, _: ApiPrincipal = Depends(get_current_active_superuser)): """ 执行命令(仅管理员) """ diff --git a/app/api/endpoints/tmdb.py b/app/api/endpoints/tmdb.py index 5b0caf9db..cfcd46c11 100644 --- a/app/api/endpoints/tmdb.py +++ b/app/api/endpoints/tmdb.py @@ -12,9 +12,8 @@ from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.tmdb import TmdbChain from app.runtime.config import settings -from app.application.security.access import verify_token -from app.db.models.user import User -from app.db.oper.systemconfig import SystemConfigOper +from app.adapters.web.security.access import verify_token +from app.application.configuration import get_configured_system_config from app.api.deps import get_current_active_superuser_async from app.schemas.types import MediaType, SystemConfigKey @@ -27,7 +26,7 @@ router = ResponseAPIRouter() response_model=_SchemaResponse[_SchemaTmdbRecognitionCacheData], ) async def tmdb_recognition_cache( - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """查询可管理的 TheMovieDb 识别缓存。""" cache_items = TmdbChain().cache_items() @@ -38,7 +37,7 @@ async def tmdb_recognition_cache( "count": len(cache_items), "recognized": recognized_count, "unrecognized": len(cache_items) - recognized_count, - "shared_recognized": SystemConfigOper().get( + "shared_recognized": get_configured_system_config().get( SystemConfigKey.MediaRecognizeShareCount ) or 0, "shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE, @@ -54,7 +53,7 @@ async def tmdb_recognition_cache( ) async def delete_tmdb_recognition_cache( cache_key: str, - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """按缓存键删除单条 TheMovieDb 识别缓存。""" deleted_item = TmdbChain().delete_cache(cache_key) @@ -67,7 +66,7 @@ async def delete_tmdb_recognition_cache( "/cache", summary="清空 TheMovieDb 识别缓存", response_model=_SchemaResponse[None] ) async def clear_tmdb_recognition_cache( - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ) -> _SchemaResponse: """清空全部 TheMovieDb 识别缓存。""" TmdbChain().clear_cache() diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 953bc6060..60972bec8 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -12,7 +12,6 @@ from app.runtime.config import settings from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo -from app.db.models import User from app.api.deps import get_current_active_superuser, get_current_active_superuser_async from app.schemas.types import ( MUSIC_ENTITY_RECORDING, @@ -32,7 +31,7 @@ router = ResponseAPIRouter() summary="获取种子缓存", response_model=_SchemaResponse[_SchemaTorrentCacheData], ) -async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): +async def torrents_cache(_: object = Depends(get_current_active_superuser_async)): """ 获取当前种子缓存数据 """ @@ -103,7 +102,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)): async def delete_cache( domain: str, torrent_hash: str, - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ): """ 删除指定的种子缓存 @@ -147,7 +146,7 @@ async def delete_cache( @router.delete("/cache", summary="清理种子缓存", response_model=_SchemaResponse[None]) -async def clear_cache(_: User = Depends(get_current_active_superuser_async)): +async def clear_cache(_: object = Depends(get_current_active_superuser_async)): """ 清理所有种子缓存 """ @@ -161,7 +160,7 @@ async def clear_cache(_: User = Depends(get_current_active_superuser_async)): @router.post("/cache/refresh", summary="刷新种子缓存", response_model=_SchemaResponse[None]) -def refresh_cache(_: User = Depends(get_current_active_superuser)): +def refresh_cache(_: object = Depends(get_current_active_superuser)): """ 刷新种子缓存 """ @@ -195,7 +194,7 @@ async def reidentify_cache( media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, music_type: Optional[MusicTargetEntityType] = None, - _: User = Depends(get_current_active_superuser_async), + _: object = Depends(get_current_active_superuser_async), ): """ 重新识别指定的种子 diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 897e2ae9a..56397da8b 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import Any, List, Annotated, Optional from fastapi import Depends -from sqlalchemy.orm import Session from app.schemas.common import NameData as _SchemaNameData from app.schemas.response import Response as _SchemaResponse @@ -19,12 +18,13 @@ from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.transfer import TransferChain from app.runtime.config import settings, global_vars -from app.application.security.access import verify_token, verify_apitoken -from app.db import get_db -from app.db.models import User -from app.db.models.transferhistory import TransferHistory -from app.api.deps import get_current_active_manage_user +from app.adapters.web.security.access import verify_token, verify_apitoken +from app.api.deps import ( + get_current_active_manage_user, + get_transfer_history_lookup_service, +) from app.application.directory import DirectoryHelper +from app.application.history import TransferHistoryLookupService from app.runtime.log import logger from app.schemas.types import MediaType from app.schemas.workflow import FileItem @@ -106,7 +106,8 @@ async def remove_queue( def _resolve_manual_transfer_source_fileitems( - transer_item: ManualTransferItem, db: Session + transer_item: ManualTransferItem, + history_query: TransferHistoryLookupService, ) -> tuple[List[FileItem], Optional[str]]: """ 从手动整理请求中解析源文件项。 @@ -114,7 +115,7 @@ def _resolve_manual_transfer_source_fileitems( if transer_item.logids: fileitems: List[FileItem] = [] for logid in transer_item.logids: - history: TransferHistory = TransferHistory.get(db, logid) + history = history_query.get(logid) if not history: return [], f"整理记录不存在,ID:{logid}" if history.status and ("move" in history.mode): @@ -124,7 +125,7 @@ def _resolve_manual_transfer_source_fileitems( return fileitems, None if transer_item.logid: - history: TransferHistory = TransferHistory.get(db, transer_item.logid) + history = history_query.get(transer_item.logid) if not history: return [], f"整理记录不存在,ID:{transer_item.logid}" if history.status and ("move" in history.mode): @@ -195,19 +196,21 @@ def _get_manual_transfer_target_key( ) def match_manual_transfer_target_path( transer_item: ManualTransferItem, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + history_query: TransferHistoryLookupService = Depends( + get_transfer_history_lookup_service + ), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 根据源文件匹配手动整理目的路径。 :param transer_item: 手工整理项 - :param db: 数据库 + :param history_query: 整理历史投影服务 :param _: Token校验 """ src_fileitems, error_message = _resolve_manual_transfer_source_fileitems( transer_item=transer_item, - db=db, + history_query=history_query, ) if error_message: return _SchemaResponse(success=False, message=error_message) @@ -258,19 +261,21 @@ def match_manual_transfer_target_path( ) def query_manual_transfer_history( transer_item: ManualTransferItem, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + history_query: TransferHistoryLookupService = Depends( + get_transfer_history_lookup_service + ), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 查询文件或目录命中的成功整理记录。 :param transer_item: 手工整理项 - :param db: 数据库 + :param history_query: 整理历史投影服务 :param _: Token校验 """ src_fileitems, error_message = _resolve_manual_transfer_source_fileitems( transer_item=transer_item, - db=db, + history_query=history_query, ) if error_message: return _SchemaResponse(success=False, message=error_message) @@ -293,14 +298,16 @@ def query_manual_transfer_history( def manual_transfer( transer_item: ManualTransferItem, background: Optional[bool] = False, - db: Session = Depends(get_db), - _: User = Depends(get_current_active_manage_user), + history_query: TransferHistoryLookupService = Depends( + get_transfer_history_lookup_service + ), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 手动转移,文件或历史记录,支持自定义剧集识别格式 :param transer_item: 手工整理项 :param background: 后台运行 - :param db: 数据库 + :param history_query: 整理历史投影服务 :param _: Token校验 """ force = False @@ -311,7 +318,7 @@ def manual_transfer( target_path = Path(transer_item.target_path) if transer_item.target_path else None if transer_item.logid: # 查询历史记录 - history: TransferHistory = TransferHistory.get(db, transer_item.logid) + history = history_query.get(transer_item.logid) if not history: return _SchemaResponse( success=False, message=f"整理记录不存在,ID:{transer_item.logid}" @@ -592,7 +599,7 @@ def manual_transfer( ) def recommend_episode_format( recommend_item: EpisodeFormatRecommendItem, - _: User = Depends(get_current_active_manage_user), + _: object = Depends(get_current_active_manage_user), ) -> Any: """ 根据目录样本推荐集数定位模板 diff --git a/app/api/endpoints/user.py b/app/api/endpoints/user.py index 2c88ec518..f57e9ac70 100644 --- a/app/api/endpoints/user.py +++ b/app/api/endpoints/user.py @@ -3,7 +3,6 @@ import re from typing import Annotated, Any, List, Union from fastapi import Body, Depends, HTTPException, UploadFile, File -from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.common import FileNameData as _SchemaFileNameData from app.schemas.common import ValueData as _SchemaValueData @@ -12,37 +11,41 @@ from app.schemas.user import User as _SchemaUser from app.schemas.user import UserCreate as _SchemaUserCreate from app.schemas.user import UserUpdate as _SchemaUserUpdate from app.api.response import ResponseAPIRouter -from app.application.security.access import PasswordTooLongError, get_password_hash -from app.db import get_async_db -from app.db.models.user import User -from app.api.deps import get_current_active_superuser_async, get_current_active_user_async, get_current_active_user -from app.db.oper.userconfig import UserConfigOper +from app.application.security.token import PasswordTooLongError, get_password_hash +from app.application.security.user import UserService +from app.api.deps import ( + get_current_active_superuser_async, + get_current_active_user_async, + get_current_active_user, + get_user_service, +) +from app.application.security.userconfig import get_configured_user_configuration router = ResponseAPIRouter() @router.get("/", summary="所有用户", response_model=List[_SchemaUser]) async def list_users( - db: AsyncSession = Depends(get_async_db), - current_user: User = Depends(get_current_active_superuser_async), + service: UserService = Depends(get_user_service), + current_user: Any = Depends(get_current_active_superuser_async), ) -> Any: """ 查询用户列表 """ - return await current_user.async_list(db) + return await service.list() @router.post("/", summary="新增用户", response_model=_SchemaResponse[None]) async def create_user( *, - db: AsyncSession = Depends(get_async_db), + service: UserService = Depends(get_user_service), user_in: _SchemaUserCreate, - current_user: User = Depends(get_current_active_superuser_async), + current_user: Any = Depends(get_current_active_superuser_async), ) -> Any: """ 新增用户 """ - user = await current_user.async_get_by_name(db, name=user_in.name) + user = await service.get_by_name(user_in.name) if user: return _SchemaResponse(success=False, message="用户已存在") user_info = user_in.model_dump() @@ -52,16 +55,16 @@ async def create_user( except PasswordTooLongError as error: return _SchemaResponse(success=False, message=str(error)) user_info.pop("password") - user = await User(**user_info).async_create(db) + user = await service.create(user_info) return _SchemaResponse(success=True if user else False) @router.put("/", summary="更新用户", response_model=_SchemaResponse[None]) async def update_user( *, - db: AsyncSession = Depends(get_async_db), + service: UserService = Depends(get_user_service), user_in: _SchemaUserUpdate, - current_user: User = Depends(get_current_active_superuser_async), + current_user: Any = Depends(get_current_active_superuser_async), ) -> Any: """ 更新用户 @@ -80,24 +83,24 @@ async def update_user( except PasswordTooLongError as error: return _SchemaResponse(success=False, message=str(error)) user_info.pop("password") - user = await current_user.async_get_by_id(db, user_id=user_info["id"]) + user = await service.get_by_id(user_info["id"]) user_name = user_info.get("name") if not user_name: return _SchemaResponse(success=False, message="用户名不能为空") # 新用户名去重 - users = await current_user.async_list(db) + users = await service.list() for u in users: if u.name == user_name and u.id != user_info["id"]: return _SchemaResponse(success=False, message="用户名已被使用") if not user: return _SchemaResponse(success=False, message="用户不存在") - await user.async_update(db, user_info) + await service.update(user_info["id"], user_info) return _SchemaResponse(success=True) @router.get("/current", summary="当前登录用户信息", response_model=_SchemaUser) async def read_current_user( - current_user: User = Depends(get_current_active_user_async), + current_user: Any = Depends(get_current_active_user_async), ) -> Any: """ 当前登录用户信息 @@ -112,9 +115,9 @@ async def read_current_user( ) async def upload_avatar( user_id: int, - db: AsyncSession = Depends(get_async_db), + service: UserService = Depends(get_user_service), file: UploadFile = File(...), - current_user: User = Depends(get_current_active_user_async), + current_user: Any = Depends(get_current_active_user_async), ) -> _SchemaResponse: """ 上传用户头像 @@ -125,10 +128,10 @@ async def upload_avatar( # 将文件转换为Base64 file_base64 = base64.b64encode(file.file.read()) # 更新到用户表 - user = await User.async_get(db, user_id) + user = await service.get_by_id(user_id) if not user: return _SchemaResponse(success=False, message="用户不存在") - await user.async_update(db, {"avatar": f"data:image/ico;base64,{file_base64}"}) + await service.update(user_id, {"avatar": f"data:image/ico;base64,{file_base64}"}) return _SchemaResponse(success=True, data={"filename": file.filename}) @@ -137,11 +140,11 @@ async def upload_avatar( summary="查询用户配置", response_model=_SchemaResponse[_SchemaValueData], ) -def get_config(key: str, current_user: User = Depends(get_current_active_user)): +def get_config(key: str, current_user: Any = Depends(get_current_active_user)): """ 查询用户配置 """ - value = UserConfigOper().get(username=current_user.name, key=key) + value = get_configured_user_configuration().get(username=current_user.name, key=key) return _SchemaResponse(success=True, data={"value": value}) @@ -149,59 +152,63 @@ def get_config(key: str, current_user: User = Depends(get_current_active_user)): def set_config( key: str, value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None, - current_user: User = Depends(get_current_active_user), + current_user: Any = Depends(get_current_active_user), ): """ 更新用户配置 """ - UserConfigOper().set(username=current_user.name, key=key, value=value) + get_configured_user_configuration().set( + username=current_user.name, + key=key, + value=value, + ) return _SchemaResponse(success=True) @router.delete("/id/{user_id}", summary="删除用户", response_model=_SchemaResponse[None]) async def delete_user_by_id( *, - db: AsyncSession = Depends(get_async_db), + service: UserService = Depends(get_user_service), user_id: int, - current_user: User = Depends(get_current_active_superuser_async), + current_user: Any = Depends(get_current_active_superuser_async), ) -> Any: """ 通过唯一ID删除用户 """ - user = await current_user.async_get_by_id(db, user_id=user_id) + user = await service.get_by_id(user_id) if not user: return _SchemaResponse(success=False, message="用户不存在") - await current_user.async_delete(db, user_id) + await service.delete(user_id) return _SchemaResponse(success=True) @router.delete("/name/{user_name}", summary="删除用户", response_model=_SchemaResponse[None]) async def delete_user_by_name( *, - db: AsyncSession = Depends(get_async_db), + service: UserService = Depends(get_user_service), user_name: str, - current_user: User = Depends(get_current_active_superuser_async), + current_user: Any = Depends(get_current_active_superuser_async), ) -> Any: """ 通过用户名删除用户 """ - user = await current_user.async_get_by_name(db, name=user_name) + user = await service.get_by_name(user_name) if not user: return _SchemaResponse(success=False, message="用户不存在") - await current_user.async_delete(db, user.id) + await service.delete(user.id) return _SchemaResponse(success=True) @router.get("/{username}", summary="用户详情", response_model=_SchemaUser) async def read_user_by_name( username: str, - current_user: User = Depends(get_current_active_user_async), - db: AsyncSession = Depends(get_async_db), + current_user: Any = Depends(get_current_active_user_async), + service: UserService = Depends(get_user_service), ) -> Any: """ 查询用户详情 """ - user = await current_user.async_get_by_name(db, name=username) + user = await service.get_by_name(username) if not user: raise HTTPException( status_code=404, diff --git a/app/api/endpoints/webhook.py b/app/api/endpoints/webhook.py index 9b83de474..8ed5a22fa 100644 --- a/app/api/endpoints/webhook.py +++ b/app/api/endpoints/webhook.py @@ -5,7 +5,7 @@ from fastapi import BackgroundTasks, Request, Depends from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.chain.webhook import WebhookChain -from app.application.security.access import verify_apitoken +from app.adapters.web.security.access import verify_apitoken router = ResponseAPIRouter() diff --git a/app/api/endpoints/workflow.py b/app/api/endpoints/workflow.py index 0a3f049fb..23cfda376 100644 --- a/app/api/endpoints/workflow.py +++ b/app/api/endpoints/workflow.py @@ -1,7 +1,6 @@ from typing import List, Any, Optional from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.response import Response as _SchemaResponse from app.schemas.workflow import NameValueOption as _SchemaNameValueOption @@ -10,19 +9,21 @@ from app.schemas.workflow import Workflow as _SchemaWorkflow from app.schemas.workflow import WorkflowActionDefinition as _SchemaWorkflowActionDefinition from app.schemas.workflow import WorkflowShare as _SchemaWorkflowShare from app.api.response import ResponseAPIRouter -from app.application.workflow import WorkflowDefinitionCommand, WorkflowMutationCommand +from app.application.workflow import ( + WorkflowDefinitionCommand, + WorkflowMutationCommand, + WorkflowQueryService, +) from app.chain.workflow import WorkflowChain -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager as PluginManager from app.workflow import WorkFlowManager -from app.db import get_async_db -from app.db.models import User from app.api.deps import ( get_current_active_manage_user, get_current_active_manage_user_async, get_workflow_definition_command, get_workflow_mutation_command, + get_workflow_query_service, ) -from app.db.oper.workflow import WorkflowOper from app.adapters.external.server import MoviePilotServerHelper from app.schemas.types import EventType, EVENT_TYPE_NAMES @@ -30,20 +31,20 @@ router = ResponseAPIRouter() @router.get("/", summary="所有工作流", response_model=List[_SchemaWorkflow]) async def list_workflows( - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: WorkflowQueryService = Depends(get_workflow_query_service), + _: Any = Depends(get_current_active_manage_user_async), ) -> Any: """ 获取工作流列表 """ - return await WorkflowOper(db).async_list() + return await query.list() @router.post("/", summary="创建工作流", response_model=_SchemaResponse[None]) async def create_workflow( workflow: _SchemaWorkflow, command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command), - _: User = Depends(get_current_active_manage_user_async), + _: Any = Depends(get_current_active_manage_user_async), ) -> Any: """ 创建工作流 @@ -58,7 +59,7 @@ async def create_workflow( response_model=List[_SchemaPluginWorkflowActionGroup], ) def list_plugin_actions( - plugin_id: str = None, _: User = Depends(get_current_active_manage_user) + plugin_id: str = None, _: Any = Depends(get_current_active_manage_user) ) -> Any: """ 获取所有动作 @@ -71,7 +72,7 @@ def list_plugin_actions( summary="所有动作", response_model=List[_SchemaWorkflowActionDefinition], ) -async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any: +async def list_actions(_: Any = Depends(get_current_active_manage_user_async)) -> Any: """ 获取所有动作 """ @@ -83,7 +84,7 @@ async def list_actions(_: User = Depends(get_current_active_manage_user_async)) summary="获取所有事件类型", response_model=List[_SchemaNameValueOption], ) -async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any: +async def get_event_types(_: Any = Depends(get_current_active_manage_user_async)) -> Any: """ 获取所有事件类型 """ @@ -98,7 +99,7 @@ async def get_event_types(_: User = Depends(get_current_active_manage_user_async @router.post("/share", summary="分享工作流", response_model=_SchemaResponse[None]) async def workflow_share( - workflow: _SchemaWorkflowShare, _: User = Depends(get_current_active_manage_user_async) + workflow: _SchemaWorkflowShare, _: Any = Depends(get_current_active_manage_user_async) ) -> Any: """ 分享工作流 @@ -119,7 +120,7 @@ async def workflow_share( @router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None]) async def workflow_share_delete( - share_id: int, _: User = Depends(get_current_active_manage_user_async) + share_id: int, _: Any = Depends(get_current_active_manage_user_async) ) -> Any: """ 删除分享 @@ -132,7 +133,7 @@ async def workflow_share_delete( async def workflow_fork( workflow: _SchemaWorkflowShare, command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command), - _: User = Depends(get_current_active_manage_user_async), + _: Any = Depends(get_current_active_manage_user_async), ) -> Any: """ 复用工作流 @@ -148,7 +149,7 @@ async def workflow_shares( name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30, - _: User = Depends(get_current_active_manage_user_async), + _: Any = Depends(get_current_active_manage_user_async), ) -> Any: """ 查询分享的工作流 @@ -162,7 +163,7 @@ async def workflow_shares( def run_workflow( workflow_id: int, from_begin: Optional[bool] = True, - _: User = Depends(get_current_active_manage_user), + _: Any = Depends(get_current_active_manage_user), ) -> Any: """ 执行工作流 @@ -179,7 +180,7 @@ def run_workflow( def start_workflow( workflow_id: int, command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), - _: User = Depends(get_current_active_manage_user), + _: Any = Depends(get_current_active_manage_user), ) -> Any: """ 启用工作流 @@ -194,7 +195,7 @@ def start_workflow( def pause_workflow( workflow_id: int, command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), - _: User = Depends(get_current_active_manage_user), + _: Any = Depends(get_current_active_manage_user), ) -> Any: """ 停用工作流 @@ -209,7 +210,7 @@ def pause_workflow( async def reset_workflow( workflow_id: int, command: WorkflowDefinitionCommand = Depends(get_workflow_definition_command), - _: User = Depends(get_current_active_manage_user_async), + _: Any = Depends(get_current_active_manage_user_async), ) -> Any: """ 重置工作流 @@ -221,20 +222,20 @@ async def reset_workflow( @router.get("/{workflow_id}", summary="工作流详情", response_model=_SchemaWorkflow) async def get_workflow( workflow_id: int, - db: AsyncSession = Depends(get_async_db), - _: User = Depends(get_current_active_manage_user_async), + query: WorkflowQueryService = Depends(get_workflow_query_service), + _: Any = Depends(get_current_active_manage_user_async), ) -> Any: """ 获取工作流详情 """ - return await WorkflowOper(db).async_get(workflow_id) + return await query.get(workflow_id) @router.put("/{workflow_id}", summary="更新工作流", response_model=_SchemaResponse[None]) def update_workflow( workflow: _SchemaWorkflow, command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), - _: User = Depends(get_current_active_manage_user), + _: Any = Depends(get_current_active_manage_user), ) -> Any: """ 更新工作流 @@ -247,7 +248,7 @@ def update_workflow( def delete_workflow( workflow_id: int, command: WorkflowMutationCommand = Depends(get_workflow_mutation_command), - _: User = Depends(get_current_active_manage_user), + _: Any = Depends(get_current_active_manage_user), ) -> Any: """ 删除工作流 diff --git a/app/api/principal.py b/app/api/principal.py new file mode 100644 index 000000000..10b153af6 --- /dev/null +++ b/app/api/principal.py @@ -0,0 +1,14 @@ +"""API 鉴权依赖向端点暴露的最小当前用户契约。""" + +from typing import Protocol + + +class ApiPrincipal(Protocol): + """隔离端点身份判断与 ORM User 实现。""" + + id: int + name: str + is_superuser: bool + + +__all__ = ["ApiPrincipal"] diff --git a/app/api/servarr.py b/app/api/servarr.py index b26008424..77641b0b0 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -1,8 +1,6 @@ from typing import List, Optional, Annotated from fastapi import APIRouter, HTTPException, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session from app.schemas.response import Response as _SchemaResponse from app.schemas.servarr import RadarrMovie as _SchemaRadarrMovie @@ -19,9 +17,9 @@ from app.chain.subscribe import SubscribeChain from app.chain.tvdb import TvdbChain from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo -from app.application.security.access import verify_apikey -from app.db import get_db, get_async_db -from app.db.models.subscribe import Subscribe +from app.application.servarr import ServarrSubscription, ServarrSubscriptionService +from app.adapters.web.security.access import verify_apikey +from app.api.deps import get_servarr_subscription_service from app.schemas.servarr import RadarrMovie from app.schemas.servarr import SonarrSeries from app.schemas.types import MediaSource, MediaType @@ -30,7 +28,7 @@ from version import APP_VERSION arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES) -def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None: +def _subscribe_tmdb_id(subscribe: ServarrSubscription) -> int | None: """将通用订阅身份投影为 Servarr 固定使用的 TMDB ID。""" if ( subscribe.media_source == MediaSource.TMDB.value @@ -220,7 +218,11 @@ async def arr_languageprofile( "/movie", summary="所有订阅电影", response_model=List[_SchemaRadarrMovie] ) async def arr_movies( - _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db) + _: Annotated[str, Depends(verify_apikey)], + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> List[_SchemaRadarrMovie]: """ 查询Rardar电影 @@ -292,7 +294,7 @@ async def arr_movies( """ # 查询所有电影订阅 result = [] - subscribes = await Subscribe.async_list(db) + subscribes = await subscriptions.list() for subscribe in subscribes: if subscribe.type != MediaType.MOVIE.value: continue @@ -316,7 +318,12 @@ async def arr_movies( "/movie/lookup", summary="查询电影", response_model=List[_SchemaRadarrMovie] ) def arr_movie_lookup( - term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db) + term: str, + _: Annotated[str, Depends(verify_apikey)], + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> List[_SchemaRadarrMovie]: """ 查询Rardar电影 term: `tmdb:${id}` @@ -340,8 +347,9 @@ def arr_movie_lookup( # 文件存在 hasfile = True # 查询是否已订阅 - subscribes = Subscribe.list_by_media_identity( - db, MediaSource.TMDB.value, tmdbid + subscribes = subscriptions.list_by_media_identity_sync( + MediaSource.TMDB, + tmdbid, ) if subscribes: # 订阅ID @@ -376,12 +384,15 @@ def arr_movie_lookup( async def arr_movie( mid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaRadarrMovie: """ 查询Rardar电影订阅 """ - subscribe = await Subscribe.async_get(db, mid) + subscribe = await subscriptions.get(mid) if subscribe: return RadarrMovie( id=subscribe.id, @@ -404,14 +415,18 @@ async def arr_movie( async def arr_add_movie( _: Annotated[str, Depends(verify_apikey)], movie: RadarrMovie, - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaServarrIdResponse: """ 新增Rardar电影订阅 """ # 检查订阅是否已存在 - subscribes = await Subscribe.async_list_by_media_identity( - db, MediaSource.TMDB.value, str(movie.tmdbId) + subscribes = await subscriptions.list_by_media_identity( + MediaSource.TMDB, + str(movie.tmdbId), ) if subscribes: return _SchemaServarrIdResponse(id=subscribes[0].id) @@ -436,14 +451,15 @@ async def arr_add_movie( async def arr_remove_movie( mid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaResponse[None]: """ 删除Rardar电影订阅 """ - subscribe = await Subscribe.async_get(db, mid) - if subscribe: - await subscribe.async_delete(db, mid) + if await subscriptions.delete(mid): return _SchemaResponse(success=True) else: raise HTTPException(status_code=404, detail="未找到该电影!") @@ -453,7 +469,11 @@ async def arr_remove_movie( "/series", summary="所有剧集", response_model=List[_SchemaSonarrSeries] ) async def arr_series( - _: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db) + _: Annotated[str, Depends(verify_apikey)], + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> List[_SchemaSonarrSeries]: """ 查询Sonarr剧集 @@ -562,7 +582,7 @@ async def arr_series( """ # 查询所有电视剧订阅 result = [] - subscribes = await Subscribe.async_list(db) + subscribes = await subscriptions.list() for subscribe in subscribes: if subscribe.type != MediaType.TV.value: continue @@ -597,7 +617,12 @@ async def arr_series( response_model=List[_SchemaSonarrSeries], ) def arr_series_lookup( - term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db) + term: str, + _: Annotated[str, Depends(verify_apikey)], + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> List[_SchemaSonarrSeries]: """ 查询Sonarr剧集 term: `tvdb:${id}` title @@ -651,8 +676,9 @@ def arr_series_lookup( # 查询订阅信息 seasons: List[dict] = [] - subscribes = Subscribe.list_by_media_identity( - db, MediaSource.TMDB.value, str(mediainfo.tmdb_id) + subscribes = subscriptions.list_by_media_identity_sync( + MediaSource.TMDB, + str(mediainfo.tmdb_id), ) if subscribes: # 已监控 @@ -711,12 +737,15 @@ def arr_series_lookup( async def arr_serie( tid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaSonarrSeries: """ 查询Sonarr剧集 """ - subscribe = await Subscribe.async_get(db, tid) + subscribe = await subscriptions.get(tid) if subscribe: return SonarrSeries( id=subscribe.id, @@ -748,7 +777,10 @@ async def arr_serie( async def arr_add_series( tv: _SchemaSonarrSeries, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaServarrIdResponse: """ 新增Sonarr剧集订阅 @@ -788,9 +820,8 @@ async def arr_add_series( for season, monitored in seasons: if not monitored: continue - subscribe = await Subscribe.async_exists( - db, - media_source=MediaSource.TMDB.value, + subscribe = await subscriptions.exists( + media_source=MediaSource.TMDB, media_id=str(tv.tmdbId), season=season, ) @@ -826,12 +857,15 @@ async def arr_add_series( async def arr_update_series( tv: _SchemaSonarrSeries, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaServarrIdResponse: """ 更新Sonarr剧集订阅 """ - return await arr_add_series(tv=tv, _=_, db=db) + return await arr_add_series(tv=tv, _=_, subscriptions=subscriptions) @arr_router.delete( @@ -840,14 +874,15 @@ async def arr_update_series( async def arr_remove_series( tid: int, _: Annotated[str, Depends(verify_apikey)], - db: AsyncSession = Depends(get_async_db), + subscriptions: Annotated[ + ServarrSubscriptionService, + Depends(get_servarr_subscription_service), + ], ) -> _SchemaResponse[None]: """ 删除Sonarr剧集订阅 """ - subscribe = await Subscribe.async_get(db, tid) - if subscribe: - await subscribe.async_delete(db, tid) + if await subscriptions.delete(tid): return _SchemaResponse(success=True) else: raise HTTPException(status_code=404, detail="未找到该电视剧!") diff --git a/app/application/agentdata.py b/app/application/agentdata.py new file mode 100644 index 000000000..0050ac3a3 --- /dev/null +++ b/app/application/agentdata.py @@ -0,0 +1,127 @@ +"""Agent 编排和工具使用的数据端口组合根注册表。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +AgentDataFactory = Callable[[], Any] + + +class _PortMeta(type): + """支持旧测试按 Oper 方法打桩的端口代理元类。""" + + def __getattr__(cls, name: str) -> Any: + """把类级方法访问转发到当前配置端口。""" + return getattr(cls(), name) + + +class _PortProxy(metaclass=_PortMeta): + """将存量 Oper 调用形态转发到 Agent 数据端口。""" + + port_name: str + + def __getattr__(self, name: str) -> Any: + """转发未被测试替换的实例方法。""" + ports = get_agent_data_ports() + return getattr(getattr(ports, self.port_name)(), name) + + +class AgentChatPort(_PortProxy): + """Agent 会话数据端口代理。""" + + port_name = "agent_chat" + + +class AgentTaskPort(_PortProxy): + """Agent 定时任务数据端口代理。""" + + port_name = "agent_task" + + +class UserPort(_PortProxy): + """用户数据端口代理。""" + + port_name = "user" + + +class SitePort(_PortProxy): + """站点数据端口代理。""" + + port_name = "site" + + +class SubscribePort(_PortProxy): + """订阅数据端口代理。""" + + port_name = "subscribe" + + +class SubscribeHistoryPort(_PortProxy): + """订阅历史数据端口代理。""" + + port_name = "subscribe_history" + + +class TransferHistoryPort(_PortProxy): + """整理历史数据端口代理。""" + + port_name = "transfer_history" + + +class DownloadHistoryPort(_PortProxy): + """下载历史数据端口代理。""" + + port_name = "download_history" + + +class WorkflowPort(_PortProxy): + """工作流数据端口代理。""" + + port_name = "workflow" + + +class PluginDataPort(_PortProxy): + """插件数据端口代理。""" + + port_name = "plugin_data" + + +class AgentDataPorts: + """Agent 入口所需的持久化端口集合。""" + + def __init__(self, **factories: AgentDataFactory) -> None: + """保存各数据能力的工厂。""" + self.__dict__.update(factories) + + +_ports: AgentDataPorts | None = None + + +def configure_agent_data_ports(**factories: AgentDataFactory) -> None: + """由启动组合根登记 Agent 数据端口实现。""" + required = { + "agent_chat", + "agent_task", + "user", + "site", + "subscribe", + "subscribe_history", + "transfer_history", + "download_history", + "workflow", + "plugin_data", + } + missing = sorted(required - factories.keys()) + if missing: + raise ValueError(f"Agent 数据端口缺少实现: {', '.join(missing)}") + global _ports + _ports = AgentDataPorts(**{name: factories[name] for name in required}) + + +def get_agent_data_ports() -> AgentDataPorts: + """返回已登记的 Agent 数据端口。""" + if _ports is None: + raise RuntimeError("Agent 数据端口尚未配置") + return _ports diff --git a/app/application/chain/context.py b/app/application/chain/context.py index 583961b4c..b96d1e612 100644 --- a/app/application/chain/context.py +++ b/app/application/chain/context.py @@ -6,15 +6,11 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Optional -from app.application.messaging.message import MessageHelper, MessageQueueManager -from app.db.oper.message import MessageOper -from app.runtime.cache import AsyncFileCache, FileCache -from app.runtime.events import EventManager -from app.runtime.extensions.module_manager import ModuleManager -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.chain.data import ChainDataPorts MessageQueueFactory = Callable[[Callable[..., Any]], Any] +ModuleDispatcherFactory = Callable[..., Any] ChainRuntimeContextProvider = Callable[[], "ChainRuntimeContext"] @@ -30,33 +26,24 @@ class ChainRuntimeContext: file_cache: Any async_file_cache: Any message_queue_factory: MessageQueueFactory + module_dispatcher_factory: ModuleDispatcherFactory + data_ports: Optional[ChainDataPorts] = None -def build_default_chain_runtime_context() -> ChainRuntimeContext: - """按旧构造规则创建上下文,同时复用各管理器既有单例身份。""" - return ChainRuntimeContext( - module_manager=ModuleManager(), - plugin_manager=PluginManager(), - event_manager=EventManager(), - message_oper=MessageOper(), - message_helper=MessageHelper(), - file_cache=FileCache(), - async_file_cache=AsyncFileCache(), - message_queue_factory=lambda callback: MessageQueueManager( - send_callback=callback - ), - ) +def _unconfigured_chain_runtime_context() -> ChainRuntimeContext: + """拒绝在组合根装配前隐式抓取全局管理器。""" + raise RuntimeError("Chain 运行上下文尚未由启动组合根配置") -_context_provider: ChainRuntimeContextProvider = build_default_chain_runtime_context +_context_provider: ChainRuntimeContextProvider = _unconfigured_chain_runtime_context def configure_chain_runtime_context_provider( provider: Optional[ChainRuntimeContextProvider], ) -> None: - """由组合根替换 Chain 上下文来源;传入空值恢复兼容默认值。""" + """由组合根替换 Chain 上下文来源;传入空值恢复未配置状态。""" global _context_provider - _context_provider = provider or build_default_chain_runtime_context + _context_provider = provider or _unconfigured_chain_runtime_context def get_chain_runtime_context() -> ChainRuntimeContext: diff --git a/app/application/chain/data.py b/app/application/chain/data.py new file mode 100644 index 000000000..d5aedd4bf --- /dev/null +++ b/app/application/chain/data.py @@ -0,0 +1,176 @@ +"""Chain 所需持久化端口的组合根注册表。 + +Chain 只依赖本模块声明的工厂,不再直接导入数据库 Oper 或 ORM 模型。 +具体适配器由 ``app.startup`` 在进程启动时装配,测试也可以登记隔离替身。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional + + +OperFactory = Callable[[], Any] + + +@dataclass(frozen=True, slots=True) +class ChainDataPorts: + """跨领域 Chain 使用的最小持久化端口工厂集合。""" + + site: OperFactory + subscribe: OperFactory + workflow: OperFactory + download_history: OperFactory + transfer_history: OperFactory + transfer_pending: OperFactory + media_server: OperFactory + download_failure: OperFactory + user: OperFactory + + +class _PortProxyMeta(type): + """让迁移期的 Oper 名称支持按方法打桩,同时仍转发到组合根端口。""" + + def __getattr__(cls, name: str) -> Any: + """把类级方法访问转发到一个新的端口实例。""" + return getattr(cls(), name) + + +class _ChainDataPortProxy(metaclass=_PortProxyMeta): + """将旧的 Oper 调用形态转发到 Chain 数据端口的内部代理。""" + + port_name: str + + def __getattr__(self, name: str) -> Any: + """转发未被测试替换的数据操作。""" + return getattr(getattr(get_chain_data_ports(), self.port_name)(), name) + + +class SitePortProxy(_ChainDataPortProxy): + """站点数据端口代理。""" + + port_name = "site" + + +class SubscribePortProxy(_ChainDataPortProxy): + """订阅数据端口代理。""" + + port_name = "subscribe" + + +class WorkflowPortProxy(_ChainDataPortProxy): + """工作流数据端口代理。""" + + port_name = "workflow" + + +class DownloadHistoryPortProxy(_ChainDataPortProxy): + """下载历史数据端口代理。""" + + port_name = "download_history" + + +class TransferHistoryPortProxy(_ChainDataPortProxy): + """整理历史数据端口代理。""" + + port_name = "transfer_history" + + +class TransferPendingPortProxy(_ChainDataPortProxy): + """待整理数据端口代理。""" + + port_name = "transfer_pending" + + +class MediaServerPortProxy(_ChainDataPortProxy): + """媒体服务器数据端口代理。""" + + port_name = "media_server" + + +class DownloadFailurePortProxy(_ChainDataPortProxy): + """下载失败数据端口代理。""" + + port_name = "download_failure" + + +class UserPortProxy(_ChainDataPortProxy): + """用户数据端口代理。""" + + port_name = "user" + + +_ports: Optional[ChainDataPorts] = None + + +def configure_chain_data_ports(**factories: OperFactory) -> None: + """由启动组合根登记 Chain 的数据端口实现。""" + required = { + "site", + "subscribe", + "workflow", + "download_history", + "transfer_history", + "transfer_pending", + "media_server", + "download_failure", + "user", + } + missing = sorted(required - factories.keys()) + if missing: + raise ValueError(f"Chain 数据端口缺少实现: {', '.join(missing)}") + global _ports + _ports = ChainDataPorts(**{name: factories[name] for name in required}) + + +def get_chain_data_ports() -> ChainDataPorts: + """返回启动阶段登记的 Chain 数据端口。""" + if _ports is None: + raise RuntimeError("Chain 数据端口尚未配置") + return _ports + + +def get_chain_site_port() -> Any: + """创建站点数据端口实例。""" + return get_chain_data_ports().site() + + +def get_chain_subscribe_port() -> Any: + """创建订阅数据端口实例。""" + return get_chain_data_ports().subscribe() + + +def get_chain_workflow_port() -> Any: + """创建工作流数据端口实例。""" + return get_chain_data_ports().workflow() + + +def get_chain_download_history_port() -> Any: + """创建下载历史数据端口实例。""" + return get_chain_data_ports().download_history() + + +def get_chain_transfer_history_port() -> Any: + """创建整理历史数据端口实例。""" + return get_chain_data_ports().transfer_history() + + +def get_chain_transfer_pending_port() -> Any: + """创建待整理数据端口实例。""" + return get_chain_data_ports().transfer_pending() + + +def get_chain_media_server_port() -> Any: + """创建媒体服务器数据端口实例。""" + return get_chain_data_ports().media_server() + + +def get_chain_download_failure_port() -> Any: + """创建下载失败数据端口实例。""" + return get_chain_data_ports().download_failure() + + +def get_chain_user_port() -> Any: + """创建用户数据端口实例。""" + return get_chain_data_ports().user() diff --git a/app/application/configuration.py b/app/application/configuration.py new file mode 100644 index 000000000..c58d731da --- /dev/null +++ b/app/application/configuration.py @@ -0,0 +1,68 @@ +"""系统配置应用服务与组合根注入点。""" + +from __future__ import annotations + +from typing import Any, Protocol + + +class ConfigurationRepository(Protocol): + """配置服务所需的最小持久化端口。""" + + def get(self, key: Any = None) -> Any: + """读取配置。""" + + def set(self, key: Any, value: Any) -> bool | None: + """写入配置。""" + + async def async_get(self, key: Any = None) -> Any: + """异步读取配置。""" + + async def async_set(self, key: Any, value: Any) -> bool | None: + """异步写入配置。""" + + def delete(self, key: Any) -> Any: + """删除配置。""" + + +class SystemConfigService: + """系统配置读写应用服务。""" + + def __init__(self, repository: ConfigurationRepository) -> None: + """注入配置数据端口。""" + self._repository = repository + + def get(self, key: Any = None) -> Any: + """读取配置。""" + return self._repository.get(key) + + def set(self, key: Any, value: Any) -> bool | None: + """写入配置。""" + return self._repository.set(key, value) + + async def async_get(self, key: Any = None) -> Any: + """异步读取配置。""" + return await self._repository.async_get(key) + + async def async_set(self, key: Any, value: Any) -> bool | None: + """异步写入配置。""" + return await self._repository.async_set(key, value) + + def delete(self, key: Any) -> Any: + """删除配置。""" + return self._repository.delete(key) + + +_configured_system_config: SystemConfigService | None = None + + +def configure_system_config(service: SystemConfigService) -> None: + """由启动组合根登记系统配置服务。""" + global _configured_system_config + _configured_system_config = service + + +def get_configured_system_config() -> SystemConfigService: + """返回启动阶段登记的系统配置服务。""" + if _configured_system_config is None: + raise RuntimeError("系统配置服务尚未配置") + return _configured_system_config diff --git a/app/application/dashboard.py b/app/application/dashboard.py new file mode 100644 index 000000000..7ce3c7ce1 --- /dev/null +++ b/app/application/dashboard.py @@ -0,0 +1,64 @@ +"""Dashboard 统计查询用例。""" + +from collections.abc import Callable +from typing import Any, Optional, Protocol + +from app.schemas.dashboard import Statistic + + +class TransferHistoryQueryRepository(Protocol): + """Dashboard 所需的整理历史统计端口。""" + + def monthly_media_statistics(self) -> tuple[int, int, int, int]: + """返回本月电影、剧集、单集和音乐数量。""" + ... + + async def async_statistic(self, days: int = 7) -> list[Any]: + """返回最近若干天的整理趋势。""" + ... + + +class DashboardQueryService: + """汇总媒体服务数据与整理历史统计。""" + + def __init__( + self, + *, + repository: TransferHistoryQueryRepository, + media_statistics: Callable[[Optional[str]], Optional[list[Statistic]]], + ) -> None: + """保存整理历史端口和媒体服务统计提供方。""" + self._repository = repository + self._media_statistics = media_statistics + + def statistic(self, name: Optional[str] = None) -> Statistic: + """返回媒体服务总量和本月新增量。""" + media_statistics = self._media_statistics(name) + if media_statistics: + result = Statistic() + has_episode_count = False + for item in media_statistics: + result.movie_count += item.movie_count or 0 + result.tv_count += item.tv_count or 0 + result.music_count += item.music_count or 0 + result.user_count += item.user_count or 0 + if item.episode_count is not None: + result.episode_count += item.episode_count or 0 + has_episode_count = True + if not has_episode_count: + result.episode_count = None + else: + result = Statistic() + + ( + result.movie_count_month, + result.tv_count_month, + result.episode_count_month, + result.music_count_month, + ) = self._repository.monthly_media_statistics() + return result + + async def transfer(self, days: int = 7) -> list[int]: + """返回最近若干天的整理数量序列。""" + rows = await self._repository.async_statistic(days) + return [row[1] for row in rows] diff --git a/app/application/database.py b/app/application/database.py new file mode 100644 index 000000000..8668d00d9 --- /dev/null +++ b/app/application/database.py @@ -0,0 +1,37 @@ +"""数据库连通性应用服务。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Optional + + +DatabaseProbe = Callable[[], Optional[str]] + + +class DatabaseHealthService: + """为模块和诊断入口提供不暴露会话实现的数据库探测能力。""" + + def __init__(self, probe: DatabaseProbe) -> None: + """保存由组合根提供的数据库探测端口。""" + self._probe = probe + + def test(self) -> Optional[str]: + """执行数据库探测,成功返回空值,失败返回说明。""" + return self._probe() + + +_configured_database_health: DatabaseHealthService | None = None + + +def configure_database_health(service: DatabaseHealthService) -> None: + """由启动组合根登记数据库探测服务。""" + global _configured_database_health + _configured_database_health = service + + +def get_configured_database_health() -> DatabaseHealthService: + """返回启动阶段登记的数据库探测服务。""" + if _configured_database_health is None: + raise RuntimeError("数据库探测服务尚未配置") + return _configured_database_health diff --git a/app/application/directory.py b/app/application/directory.py index 3a5207040..b683cedf0 100644 --- a/app/application/directory.py +++ b/app/application/directory.py @@ -5,7 +5,7 @@ from typing import List, Optional, Tuple from app.schemas.file import FileURI as _SchemaFileURI from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf from app.domain.context import MediaInfo -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import MediaType, StorageSchema, SystemConfigKey from app.adapters.system.host import SystemUtils @@ -25,7 +25,7 @@ class DirectoryHelper: """ 获取所有下载目录 """ - dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories) + dir_confs: List[dict] = get_configured_system_config().get(SystemConfigKey.Directories) if not dir_confs: return [] return [_SchemaTransferDirectoryConf(**d) for d in dir_confs] diff --git a/app/application/downloader.py b/app/application/downloader.py index 5fb31ab3a..42e28ff96 100644 --- a/app/application/downloader.py +++ b/app/application/downloader.py @@ -1,6 +1,6 @@ from typing import Optional -from app.runtime.extensions.service_registry import ServiceBaseHelper +from app.application.service import ServiceBaseHelper from app.schemas.system import DownloaderConf from app.schemas.system import ServiceInfo from app.schemas.types import SystemConfigKey, ModuleType diff --git a/app/application/history.py b/app/application/history.py index b66fa68e8..e26137df1 100644 --- a/app/application/history.py +++ b/app/application/history.py @@ -6,11 +6,15 @@ from app.domain.context import MediaInfo, MusicInfo from app.schemas.media import resolve_media_identity from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic +from app.foundation.text import cut as jieba_cut from app.runtime.cache import TTLCache from app.runtime.config import settings -from app.db.models.transferhistory import TransferHistory -from app.db.oper.transferhistory import TransferHistoryOper from app.runtime.log import logger +from app.schemas.history import ( + DownloadHistory as DownloadHistoryView, + TransferHistory as TransferHistoryView, + TransferHistoryPage, +) from app.schemas.workflow import FileItem from app.schemas.transfer import TransferInfo from app.schemas.types import MUSIC_ENTITY_RECORDING @@ -28,6 +32,59 @@ FAILED_RETRY_TTL = 24 * 3600 _failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL) +class TransferHistoryRecord(Protocol): + """整理历史用例读取的最小记录投影。""" + + id: int + status: bool + src: Optional[str] + src_storage: Optional[str] + src_fileitem: Optional[dict] + + +class TransferHistoryWriter(Protocol): + """整理历史写入和查重端口。""" + + def get_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]: + """按源路径读取记录。""" + + def get_success_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]: + """按源路径读取成功记录。""" + + def add_force(self, **payload: Any) -> Optional[TransferHistoryRecord]: + """强制写入整理历史。""" + + +_configured_transfer_history_provider: Callable[[], TransferHistoryWriter] | None = None + + +def configure_transfer_history_provider( + provider: Callable[[], TransferHistoryWriter], +) -> None: + """由启动组合根登记整理历史数据端口提供器。""" + global _configured_transfer_history_provider + _configured_transfer_history_provider = provider + + +def _get_transfer_history_writer( + writer: Optional[TransferHistoryWriter], +) -> TransferHistoryWriter: + """获取显式传入或组合根登记的整理历史数据端口。""" + if writer is not None: + return writer + if _configured_transfer_history_provider is None: + raise RuntimeError("整理历史数据端口尚未配置") + return _configured_transfer_history_provider() + + +class TransferHistoryPort: + """把监控等宿主用例的存量构造形态转发到整理历史端口。""" + + def __getattr__(self, name: str) -> Any: + """转发整理历史读写方法,避免上层直接导入数据库操作器。""" + return getattr(_get_transfer_history_writer(None), name) + + @dataclass(frozen=True, slots=True) class HistoryMutationResult: """描述历史记录维护操作是否成功及兼容提示。""" @@ -36,6 +93,216 @@ class HistoryMutationResult: message: str = "" +class AsyncDownloadHistoryQueryRepository(Protocol): + """下载历史只读用例需要的最小异步持久化端口。""" + + async def async_list_by_page( + self, + page: int = 1, + count: int = 30, + ) -> list[Any]: + """按下载时间倒序分页读取历史记录。""" + ... + + +class AsyncTransferHistoryQueryRepository(Protocol): + """整理历史列表和详情查询需要的最小异步持久化端口。""" + + async def async_get(self, historyid: int) -> Optional[Any]: + """按主键读取单条整理历史。""" + ... + + async def async_list_by_title( + self, + title: str, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + wildcard: bool = False, + ) -> list[Any]: + """按标题或路径分页读取整理历史。""" + ... + + async def async_list_by_page( + self, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + ) -> list[Any]: + """按时间倒序分页读取整理历史。""" + ... + + async def async_count(self, status: Optional[bool] = None) -> Optional[int]: + """统计指定状态的整理历史数量。""" + ... + + async def async_count_by_title( + self, + title: str, + status: Optional[bool] = None, + wildcard: bool = False, + ) -> Optional[int]: + """统计匹配标题或路径的整理历史数量。""" + ... + + +@dataclass(frozen=True, slots=True) +class ManualTransferHistory: + """手动整理准备阶段需要的稳定历史投影。""" + + id: int + status: bool + mode: Optional[str] + src_fileitem: Optional[dict] + dest_fileitem: Optional[dict] + downloader: Optional[str] + download_hash: Optional[str] + type: Optional[str] + media_source: Optional[str] + media_id: Optional[str] + music_type: Optional[str] + seasons: Optional[str] + episodes: Optional[str] + episode_group: Optional[str] + + +class TransferHistoryLookupRepository(Protocol): + """手动整理历史投影所需的同步查询端口。""" + + def get(self, history_id: int) -> Optional[Any]: + """按主键读取整理历史。""" + ... + + +class TransferHistoryLookupService: + """向同步整理用例提供脱离 ORM 会话的历史投影。""" + + def __init__(self, repository: TransferHistoryLookupRepository) -> None: + """保存整理历史只读端口。""" + self._repository = repository + + def get(self, history_id: int) -> Optional[ManualTransferHistory]: + """按主键读取手动整理所需字段。""" + record = self._repository.get(history_id) + if record is None: + return None + return ManualTransferHistory( + id=record.id, + status=bool(record.status), + mode=record.mode, + src_fileitem=record.src_fileitem, + dest_fileitem=record.dest_fileitem, + downloader=record.downloader, + download_hash=record.download_hash, + type=record.type, + media_source=record.media_source, + media_id=record.media_id, + music_type=getattr(record, "music_type", None), + seasons=record.seasons, + episodes=record.episodes, + episode_group=record.episode_group, + ) + + +class HistoryQueryService: + """提供历史列表和详情 DTO,隔离 API 与数据库模型。""" + + def __init__( + self, + *, + download_repository: AsyncDownloadHistoryQueryRepository, + transfer_repository: AsyncTransferHistoryQueryRepository, + ) -> None: + """保存下载历史和整理历史的只读端口。""" + self._download_repository = download_repository + self._transfer_repository = transfer_repository + + async def list_download( + self, + *, + page: int = 1, + count: int = 30, + ) -> list[DownloadHistoryView]: + """分页读取下载历史并转换为稳定的接口 DTO。""" + records = await self._download_repository.async_list_by_page(page, count) + return [DownloadHistoryView.model_validate(record) for record in records] + + async def list_transfer( + self, + *, + title: Optional[str] = None, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + ) -> TransferHistoryPage: + """应用历史筛选规则并返回整理历史分页 DTO。""" + if title == "失败": + title = None + status = False + elif title == "成功": + title = None + status = True + + if title: + wildcard = "*" in title or "?" in title + if wildcard: + pattern = self._glob_to_like(title) + else: + pattern = "%".join(jieba_cut(title, HMM=False)) + total = await self._transfer_repository.async_count_by_title( + pattern, + status=status, + wildcard=wildcard, + ) + records = await self._transfer_repository.async_list_by_title( + pattern, + page=page, + count=count, + status=status, + wildcard=wildcard, + ) + else: + records = await self._transfer_repository.async_list_by_page( + page=page, + count=count, + status=status, + ) + total = await self._transfer_repository.async_count(status=status) + + return TransferHistoryPage( + list=[TransferHistoryView.model_validate(record) for record in records], + total=int(total or 0), + ) + + async def get_transfer(self, history_id: int) -> Optional[TransferHistoryView]: + """读取单条整理历史 DTO,不向调用方泄漏 ORM 实例。""" + record = await self._transfer_repository.async_get(history_id) + if record is None: + return None + return TransferHistoryView.model_validate(record) + + async def get_transfers( + self, + history_ids: list[int], + ) -> tuple[list[TransferHistoryView], list[int]]: + """按输入顺序读取多条整理历史,并同时返回缺失 ID。""" + records: list[TransferHistoryView] = [] + missing_ids: list[int] = [] + for history_id in history_ids: + record = await self.get_transfer(history_id) + if record is None: + missing_ids.append(history_id) + else: + records.append(record) + return records, missing_ids + + @staticmethod + def _glob_to_like(pattern: str) -> str: + """将 glob 通配符转换为使用反斜杠转义的 SQL LIKE 模式。""" + result = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return result.replace("*", "%").replace("?", "_") + + class DownloadHistoryMutationRepository(Protocol): """下载历史删除用例需要的最小持久化端口。""" @@ -447,7 +714,7 @@ def coerce_size(size: Any) -> Optional[int]: return None -def history_src_size(history: TransferHistory) -> Optional[int]: +def history_src_size(history: TransferHistoryRecord) -> Optional[int]: """ 读取整理记录中的源文件大小。 src_fileitem 是 JSON 列,历史数据可能为空、缺 size 键甚至不是字典, @@ -458,7 +725,7 @@ def history_src_size(history: TransferHistory) -> Optional[int]: return history_src_fingerprint(history).get("size") -def history_src_fingerprint(history: TransferHistory) -> Dict[str, Any]: +def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]: """ 读取整理记录中的源文件版本指纹。 :param history: 整理记录 @@ -475,8 +742,8 @@ def history_src_fingerprint(history: TransferHistory) -> Dict[str, Any]: def resolve_history(src_path: str, storage: Optional[str] = None, - transfer_history_oper: Optional[TransferHistoryOper] = None - ) -> Optional[TransferHistory]: + transfer_history_oper: Optional[TransferHistoryWriter] = None + ) -> Optional[TransferHistoryRecord]: """ 查询源路径对应的整理记录。 @@ -488,14 +755,14 @@ def resolve_history(src_path: str, storage: Optional[str] = None, :param transfer_history_oper: 复用的历史操作对象,未传时新建 :return: 命中的整理记录,未命中时为 None """ - oper = transfer_history_oper or TransferHistoryOper() + oper = _get_transfer_history_writer(transfer_history_oper) history = oper.get_by_src(src_path, storage=storage) if history is not None and not history.status: history = oper.get_success_by_src(src_path, storage=storage) or history return history -def evaluate_history_gate(history: Optional[TransferHistory], +def evaluate_history_gate(history: Optional[TransferHistoryRecord], file_size: Optional[float] = None, file_modify_time: Optional[float] = None, fileid: Optional[str] = None, @@ -547,7 +814,7 @@ def evaluate_history_gate(history: Optional[TransferHistory], return HistoryGateAction.SKIP -def describe_history_gate(history: Optional[TransferHistory], +def describe_history_gate(history: Optional[TransferHistoryRecord], file_size: Optional[float] = None, file_modify_time: Optional[float] = None, fileid: Optional[str] = None) -> str: @@ -609,8 +876,8 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase, mediainfo: Union[MediaInfo, MusicInfo], transferinfo: TransferInfo, downloader: Optional[str] = None, download_hash: Optional[str] = None, - transfer_history_oper: Optional[TransferHistoryOper] = None - ) -> Optional[TransferHistory]: + transfer_history_oper: Optional[TransferHistoryWriter] = None + ) -> Optional[TransferHistoryRecord]: """ 新增转移成功历史记录。 :param fileitem: 源文件项 @@ -623,7 +890,7 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase, :param transfer_history_oper: 复用的历史操作对象,未传时新建 :return: 落库后的整理记录 """ - oper = transfer_history_oper or TransferHistoryOper() + oper = _get_transfer_history_writer(transfer_history_oper) media_source, media_id = resolve_media_identity(media=mediainfo) return oper.add_force( src=fileitem.path, @@ -661,8 +928,8 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, transferinfo: Optional[TransferInfo] = None, downloader: Optional[str] = None, download_hash: Optional[str] = None, - transfer_history_oper: Optional[TransferHistoryOper] = None - ) -> Optional[TransferHistory]: + transfer_history_oper: Optional[TransferHistoryWriter] = None + ) -> Optional[TransferHistoryRecord]: """ 新增转移失败历史记录。 @@ -678,7 +945,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, :param transfer_history_oper: 复用的历史操作对象,未传时新建 :return: 落库后的整理记录 """ - oper = transfer_history_oper or TransferHistoryOper() + oper = _get_transfer_history_writer(transfer_history_oper) if mediainfo and transferinfo: media_source, media_id = resolve_media_identity(media=mediainfo) his = oper.add_force( diff --git a/app/application/maintenance.py b/app/application/maintenance.py index 66e6135e5..6bc24bd5a 100644 --- a/app/application/maintenance.py +++ b/app/application/maintenance.py @@ -9,8 +9,6 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Callable, ContextManager, Dict, Optional, Protocol -from app.db.maintenance import DatabaseCleanupRepository -from app.db.session import SessionFactory from app.runtime.config import settings from app.runtime.log import logger @@ -327,11 +325,21 @@ def read_cleanup_policy() -> CleanupPolicy: def build_cleanup_service() -> DataCleanupService: - """在应用边界组装默认数据库适配器,供兼容调度门面触发。""" - return DataCleanupService( - repository=DatabaseCleanupRepository(session_factory=SessionFactory), - policy_reader=read_cleanup_policy, - ) + """返回启动组合根登记的清理服务。""" + if _configured_cleanup_service_factory is None: + raise RuntimeError("数据清理服务尚未配置") + return _configured_cleanup_service_factory() + + +_configured_cleanup_service_factory: Callable[[], DataCleanupService] | None = None + + +def configure_cleanup_service_factory( + factory: Callable[[], DataCleanupService], +) -> None: + """由启动组合根登记数据清理服务工厂。""" + global _configured_cleanup_service_factory + _configured_cleanup_service_factory = factory def _normalize_days(retention_days: Any) -> int: diff --git a/app/application/mediaserver.py b/app/application/mediaserver.py index fbaadca19..6fce86933 100644 --- a/app/application/mediaserver.py +++ b/app/application/mediaserver.py @@ -1,11 +1,11 @@ import re from collections.abc import Iterable, Mapping -from typing import Any, Optional +from typing import Any, Optional, Protocol from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem from app.domain.context import MusicInfo from app.schemas.media import normalize_media_source, resolve_media_identity -from app.runtime.extensions.service_registry import ServiceBaseHelper +from app.application.service import ServiceBaseHelper from app.schemas.system import MediaServerConf from app.schemas.system import ServiceInfo from app.schemas.types import ( @@ -16,6 +16,43 @@ from app.schemas.types import ( ) +class AsyncMediaServerQueryRepository(Protocol): + """媒体服务器本地条目查询所需的异步持久化端口。""" + + async def async_exists(self, **kwargs: Any) -> Any | None: + """按标题或统一媒体身份查找已同步条目。""" + ... + + +class MediaServerQueryService: + """封装媒体服务器本地存在性查询与 ORM 投影。""" + + def __init__(self, repository: AsyncMediaServerQueryRepository): + """使用显式媒体服务器查询端口初始化服务。""" + self._repository = repository + + async def find_item_id( + self, + *, + title: Optional[str] = None, + year: Optional[str] = None, + mtype: Optional[str] = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, + season: Optional[int] = None, + ) -> Optional[str]: + """返回匹配条目的服务器 item_id,未命中时返回 None。""" + item = await self._repository.async_exists( + title=title, + year=year, + mtype=mtype, + media_source=media_source, + media_id=media_id, + season=season, + ) + return item.item_id if item else None + + class MediaServerIdentityHelper: """将媒体服务器专有 ProviderIds 适配为统一媒体身份。""" diff --git a/app/application/messaging/chat.py b/app/application/messaging/chat.py new file mode 100644 index 000000000..ce5664cc6 --- /dev/null +++ b/app/application/messaging/chat.py @@ -0,0 +1,245 @@ +"""Agent 会话历史的查询、授权与删除应用服务。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Protocol + +from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary + + +class AgentChatPrincipal(Protocol): + """会话访问控制所需的最小用户身份。""" + + id: Any + name: Optional[str] + is_superuser: bool + + +class AsyncAgentChatRepository(Protocol): + """Agent 会话用例需要的最小异步持久化端口。""" + + async def async_list_by_page( + self, + page: int = 1, + count: int = 30, + user_id: Optional[str] = None, + username: Optional[str] = None, + ) -> list[Any]: + """分页读取用户可见的会话。""" + ... + + async def async_get( + self, + session_id: str, + user_id: Optional[str] = None, + ) -> Optional[Any]: + """按服务端会话 ID 读取记录。""" + ... + + async def async_delete( + self, + session_id: str, + user_id: Optional[str] = None, + ) -> bool: + """删除指定服务端会话。""" + ... + + def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]: + """同步读取服务端会话。""" + ... + + def save_display_messages( + self, + session_id: str, + user_id: Optional[str] = None, + messages: Optional[list[dict]] = None, + username: Optional[str] = None, + channel: Optional[Any] = None, + source: Optional[str] = None, + original_chat_id: Optional[str] = None, + client_session_id: Optional[str] = None, + ) -> Optional[Any]: + """同步保存用户可见会话消息。""" + ... + + +@dataclass(frozen=True, slots=True) +class AgentChatRecord: + """脱离 ORM 会话的 Agent 会话持久化投影。""" + + id: Optional[int] + session_id: str + client_session_id: Optional[str] + title: Optional[str] + channel: Optional[str] + source: Optional[str] + user_id: Optional[str] + username: Optional[str] + original_chat_id: Optional[str] + message_count: int + created_at: Any + updated_at: Any + messages: list[dict] + + +class AgentChatService: + """统一执行 Agent 会话查询、访问控制和删除。""" + + def __init__(self, repository: AsyncAgentChatRepository) -> None: + """保存异步会话持久化端口。""" + self._repository = repository + + async def list( + self, + principal: AgentChatPrincipal, + *, + page: int = 1, + count: int = 30, + ) -> list[AgentChatSessionSummary]: + """分页返回当前用户可见的会话摘要。""" + user_id = None if principal.is_superuser else str(principal.id) + username = None if principal.is_superuser else principal.name + records = await self._repository.async_list_by_page( + page=page, + count=count, + user_id=user_id, + username=username, + ) + return [self.to_summary(self._project(record)) for record in records] + + async def get_accessible( + self, + session_id: str, + principal: AgentChatPrincipal, + ) -> Optional[AgentChatRecord]: + """读取会话并在应用边界执行访问控制。""" + projected = await self.get(session_id) + if projected is None: + return None + if not self.can_access(projected, principal): + return None + return projected + + async def get(self, session_id: str) -> Optional[AgentChatRecord]: + """读取不附带授权判断的会话投影。""" + record = await self._repository.async_get(session_id=session_id) + if record is None: + return None + return self._project(record) + + async def delete( + self, + session_id: str, + principal: AgentChatPrincipal, + ) -> bool: + """仅在当前用户可访问时删除会话。""" + record = await self.get_accessible(session_id, principal) + if record is None: + return False + return await self._repository.async_delete(session_id=session_id) + + def get_sync(self, session_id: str) -> Optional[AgentChatRecord]: + """同步读取会话投影,供同步 Agent 编排路径使用。""" + record = self._repository.get(session_id=session_id) + return self._project(record) if record is not None else None + + def save_display_sync( + self, + *, + session_id: str, + user_id: Optional[str] = None, + messages: Optional[list[dict]] = None, + username: Optional[str] = None, + channel: Optional[Any] = None, + source: Optional[str] = None, + original_chat_id: Optional[str] = None, + client_session_id: Optional[str] = None, + ) -> Optional[AgentChatRecord]: + """同步保存用户可见消息并返回最新投影。""" + record = self._repository.save_display_messages( + session_id=session_id, + user_id=user_id, + messages=messages, + username=username, + channel=channel, + source=source, + original_chat_id=original_chat_id, + client_session_id=client_session_id, + ) + return self._project(record) if record is not None else None + + @staticmethod + def can_access( + record: AgentChatRecord, + principal: AgentChatPrincipal, + ) -> bool: + """判断用户是否拥有会话访问权。""" + if principal.is_superuser: + return True + user_id = str(principal.id) + username = str(principal.name or "") + return record.user_id == user_id or ( + bool(username) and record.username == username + ) + + @staticmethod + def to_summary(record: AgentChatRecord) -> AgentChatSessionSummary: + """把持久化投影转换为会话摘要 DTO。""" + return AgentChatSessionSummary( + id=record.id, + session_id=record.session_id, + client_session_id=record.client_session_id, + title=record.title, + channel=record.channel, + source=record.source, + user_id=record.user_id, + username=record.username, + original_chat_id=record.original_chat_id, + message_count=record.message_count, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + @classmethod + def to_detail(cls, record: AgentChatRecord) -> AgentChatSessionDetail: + """把持久化投影转换为会话详情 DTO。""" + return AgentChatSessionDetail( + **cls.to_summary(record).model_dump(), + messages=record.messages, + ) + + @staticmethod + def _project(record: Any) -> AgentChatRecord: + """立即复制 ORM 字段,避免对象越过请求级会话边界。""" + return AgentChatRecord( + id=record.id, + session_id=record.session_id, + client_session_id=record.client_session_id, + title=record.title, + channel=record.channel, + source=record.source, + user_id=record.user_id, + username=record.username, + original_chat_id=record.original_chat_id, + message_count=record.message_count or 0, + created_at=record.created_at, + updated_at=record.updated_at, + messages=list(record.display_messages or []), + ) + + +_configured_agent_chat_service: AgentChatService | None = None + + +def configure_agent_chat_service(service: AgentChatService) -> None: + """由启动组合根登记同步 Agent 会话服务。""" + global _configured_agent_chat_service + _configured_agent_chat_service = service + + +def get_configured_agent_chat_service() -> AgentChatService: + """返回启动阶段登记的 Agent 会话服务。""" + if _configured_agent_chat_service is None: + raise RuntimeError("Agent 会话服务尚未配置") + return _configured_agent_chat_service diff --git a/app/application/messaging/message.py b/app/application/messaging/message.py index f510fdc9e..756421fbb 100644 --- a/app/application/messaging/message.py +++ b/app/application/messaging/message.py @@ -8,7 +8,7 @@ import re import threading import time from datetime import datetime -from typing import Any, Literal, Optional, List, Dict, Union +from typing import Any, Literal, Optional, List, Dict, Protocol, Union from typing import Callable from jinja2 import Template @@ -18,7 +18,7 @@ from app.runtime.config import global_vars from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.message import Message from app.schemas.tmdb import TmdbEpisode @@ -29,6 +29,64 @@ from app.foundation import size as size_tools from app.foundation.crypto import HashUtils +class AsyncMessageQueryRepository(Protocol): + """消息查询用例依赖的异步持久化端口。""" + + async def async_list_by_page( + self, page: int = 1, count: int = 30 + ) -> list[Any]: + """分页读取 Web 消息。""" + ... + + async def async_list_sent_by_page( + self, + page: int = 1, + count: int = 30, + all_clear_before: Optional[str] = None, + system_clear_before: Optional[str] = None, + media_clear_before: Optional[str] = None, + ) -> list[Any]: + """分页读取清理水位之后的通知消息。""" + ... + + +class MessageQueryService: + """封装消息历史读取与持久化对象投影。""" + + def __init__(self, repository: AsyncMessageQueryRepository): + """使用显式消息查询端口初始化服务。""" + self._repository = repository + + async def list_web(self, page: int = 1, count: int = 20) -> list[dict[str, Any]]: + """分页返回可由 API schema 消费的 Web 消息字典。""" + messages = await self._repository.async_list_by_page(page=page, count=count) + result: list[dict[str, Any]] = [] + for message in messages: + try: + result.append(message.to_dict()) + except Exception as error: + logger.error(f"获取WEB消息列表失败: {str(error)}") + return result + + async def list_notifications( + self, + page: int = 1, + count: int = 20, + all_clear_before: Optional[str] = None, + system_clear_before: Optional[str] = None, + media_clear_before: Optional[str] = None, + ) -> list[dict[str, Any]]: + """分页返回清理水位之后的通知消息字典。""" + messages = await self._repository.async_list_sent_by_page( + page=page, + count=count, + all_clear_before=all_clear_before, + system_clear_before=system_clear_before, + media_clear_before=media_clear_before, + ) + return [message.to_dict() for message in messages] + + class TemplateContextBuilder: """ 模板上下文构建器。 @@ -722,7 +780,7 @@ class MessageTemplateHelper: 获取消息模板 """ try: - template_dict = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) or {} + template_dict = get_configured_system_config().get(SystemConfigKey.NotificationTemplates) or {} if isinstance(template_dict, dict): configured = template_dict.get(message.ctype.value) if str(configured or "").strip() not in {"", "{}", "{ }"}: @@ -766,7 +824,7 @@ class MessageQueueManager(metaclass=SingletonClass): 初始化配置 """ self.schedule_periods = self._parse_schedule( - SystemConfigOper().get(SystemConfigKey.NotificationSendTime) + get_configured_system_config().get(SystemConfigKey.NotificationSendTime) ) @staticmethod diff --git a/app/application/messaging/site.py b/app/application/messaging/site.py index bf4747e71..052876a39 100644 --- a/app/application/messaging/site.py +++ b/app/application/messaging/site.py @@ -1,8 +1,6 @@ import re -from typing import Callable, List, Optional, Tuple, Union +from typing import Any, Callable, List, Optional, Protocol, Tuple, Union -from app.db.models.site import Site -from app.db.oper.site import SiteOper from app.domain import site as site_rules from app.application.messaging.interaction import ( MessageGateway, @@ -22,6 +20,19 @@ from app.schemas.types import NotificationChannel site_interaction_manager = SlashInteractionManager() +class SiteInteractionRepository(Protocol): + """站点消息交互所需的同步数据端口。""" + + def list(self) -> List[Any]: + """返回站点列表。""" + + def get(self, site_id: int) -> Optional[Any]: + """按 ID 返回站点。""" + + def update(self, site_id: int, payload: dict) -> Optional[Any]: + """更新站点。""" + + class SiteInteractionHandler: """ 管理 /sites 交互会话、输入解析和站点列表渲染。 @@ -34,12 +45,14 @@ class SiteInteractionHandler: self, messenger: MessageGateway, cookie_updater: Callable[..., Tuple[bool, str]], + repository: SiteInteractionRepository, ): """ 注入消息投递接口和站点 Cookie 更新动作。 """ self._messenger = messenger self._cookie_updater = cookie_updater + self._repository = repository def remote_list( self, @@ -400,7 +413,7 @@ class SiteInteractionHandler: """ 渲染 /sites 当前页面。 """ - site_list = SiteOper().list() + site_list = self._repository.list() page_size = self._button_page_size if supports_interaction_buttons(channel) else self._text_page_size page_sites, page, total_pages = page_items(site_list, request.page, page_size) request.page = page @@ -463,7 +476,7 @@ class SiteInteractionHandler: @staticmethod def _format_site_list( - site_list: List[Site], channel: Optional[NotificationChannel] + site_list: List[Any], channel: Optional[NotificationChannel] ) -> str: """ 根据渠道能力格式化站点列表。 @@ -537,15 +550,14 @@ class SiteInteractionHandler: if not site_ids: return False, "请输入至少一个有效的站点 ID" - siteoper = SiteOper() changed = [] missing = [] for site_id in site_ids: - site = siteoper.get(site_id) + site = self._repository.get(site_id) if not site: missing.append(str(site_id)) continue - siteoper.update(site_id, {"is_active": enabled}) + self._repository.update(site_id, {"is_active": enabled}) changed.append(site.name) action = "启用" if enabled else "禁用" @@ -571,7 +583,7 @@ class SiteInteractionHandler: ) site_id = int(args[0]) - site_info = SiteOper().get(site_id) + site_info = self._repository.get(site_id) if not site_info: return False, f"站点编号 {site_id} 不存在" diff --git a/app/application/messaging/skill.py b/app/application/messaging/skill.py index 8b515d0a6..273459c76 100644 --- a/app/application/messaging/skill.py +++ b/app/application/messaging/skill.py @@ -3,9 +3,8 @@ import uuid from dataclasses import dataclass, field from datetime import datetime, timedelta from threading import Lock -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union -from app.agent.skills.registry import SkillHelper, SkillInfo from app.application.messaging.interaction import ( MessageGateway, build_navigation_buttons, @@ -17,6 +16,54 @@ from app.schemas.message import Message from app.schemas.types import NotificationChannel +class SkillCatalogPort(Protocol): + """消息层使用的技能目录能力端口,由启动组合根注入具体实现。""" + + def add_custom_market_source(self, source: str) -> Tuple[bool, str]: + """添加一个自定义技能市场来源。""" + + def remove_custom_market_source(self, source: str) -> Tuple[bool, str]: + """移除一个自定义技能市场来源。""" + + def install_market_skill(self, skill: Any) -> Tuple[bool, str]: + """安装指定的市场技能。""" + + def list_local_skills(self) -> List[Any]: + """列出本地技能。""" + + def remove_local_skill(self, skill_id: str) -> Tuple[bool, str]: + """移除指定的本地技能。""" + + def list_market_source_entries(self) -> List[Any]: + """列出技能市场来源。""" + + def list_market_skills(self, force: bool = False) -> List[Any]: + """列出市场技能。""" + + def filter_market_skills(self, skills: List[Any], query: str) -> List[Any]: + """按查询词过滤市场技能。""" + + +SkillCatalogProvider = Callable[[], SkillCatalogPort] +_skill_catalog_provider: Optional[SkillCatalogProvider] = None + + +def register_skill_catalog_provider(provider: SkillCatalogProvider) -> None: + """由启动组合根注册 Agent 技能目录实现,避免消息层依赖 Agent 具体模块。""" + global _skill_catalog_provider + _skill_catalog_provider = provider + + +def _resolve_skill_catalog() -> SkillCatalogPort: + """解析已注入的技能目录;缺少组合根装配时给出明确错误。""" + if _skill_catalog_provider is None: + raise RuntimeError( + "技能目录服务未注册:请先导入 app.startup.agent_initializer " + "完成组合根装配" + ) + return _skill_catalog_provider() + + @dataclass class PendingSkillInteraction: """ @@ -152,11 +199,12 @@ class SkillInteractionHandler: def __init__( self, messenger: MessageGateway, - skill_helper: Optional[SkillHelper] = None, + skill_catalog: Optional[SkillCatalogPort] = None, + skill_helper: Optional[SkillCatalogPort] = None, ): - """注入消息接口和技能管理能力。""" + """注入消息接口和技能目录端口,保留旧 ``skill_helper`` 关键字。""" self._messenger = messenger - self.skillhelper = skill_helper or SkillHelper() + self.skillhelper = skill_catalog or skill_helper or _resolve_skill_catalog() def remote_manage( self, @@ -1059,10 +1107,10 @@ class SkillInteractionHandler: @staticmethod def _page_items( - items: List[SkillInfo], + items: List[Any], page: int, page_size: int, - ) -> Tuple[List[SkillInfo], int, int]: + ) -> Tuple[List[Any], int, int]: """ 返回当前页的数据,并把页码钳制到有效范围内。 """ @@ -1146,7 +1194,7 @@ class SkillInteractionHandler: self, request: PendingSkillInteraction, force_market_refresh: bool = False, - ) -> List[SkillInfo]: + ) -> List[Any]: """ 获取当前 /skills 会话可见的市场技能,并应用搜索词过滤。 """ diff --git a/app/application/messaging/subscribe.py b/app/application/messaging/subscribe.py index 9de1d9fa2..6f2711265 100644 --- a/app/application/messaging/subscribe.py +++ b/app/application/messaging/subscribe.py @@ -1,7 +1,6 @@ import re -from typing import List, Optional, Protocol, Tuple, Union +from typing import Any, Callable, List, Optional, Protocol, Tuple, Union -from app.adapters.external.server import MoviePilotServerHelper from app.application.messaging.interaction import ( MessageGateway, SlashInteractionManager, @@ -12,8 +11,6 @@ from app.application.messaging.interaction import ( supports_markdown, update_or_post_message, ) -from app.db.models.subscribe import Subscribe -from app.db.oper.subscribe import SubscribeOper from app.schemas.message import Message from app.schemas.types import NotificationChannel, MediaType @@ -30,6 +27,19 @@ class SubscribeInteractionActions(Protocol): """执行订阅刷新。""" ... + +class SubscribeInteractionRepository(Protocol): + """订阅消息交互所需的同步数据端口。""" + + def list(self) -> List[Any]: + """返回订阅列表。""" + + def get(self, subscribe_id: int) -> Optional[Any]: + """按 ID 返回订阅。""" + + def delete(self, subscribe_id: int) -> Any: + """删除订阅。""" + def check(self): """执行订阅元数据检查。""" ... @@ -51,12 +61,16 @@ class SubscribeInteractionHandler: self, messenger: MessageGateway, actions: SubscribeInteractionActions, + repository: SubscribeInteractionRepository, + report_deleted: Callable[[dict], Any], ): """ 注入消息投递接口和订阅业务动作。 """ self._messenger = messenger self._actions = actions + self._repository = repository + self._report_deleted = report_deleted def remote_list( self, @@ -401,7 +415,7 @@ class SubscribeInteractionHandler: """ 渲染 /subscribes 当前页面。 """ - subscribes = SubscribeOper().list() + subscribes = self._repository.list() page_size = ( self._button_page_size if supports_interaction_buttons(channel) @@ -475,7 +489,7 @@ class SubscribeInteractionHandler: ) def _format_subscribe_list( - self, subscribes: List[Subscribe], channel: Optional[NotificationChannel] + self, subscribes: List[Any], channel: Optional[NotificationChannel] ) -> str: """ 根据渠道能力格式化订阅列表。 @@ -521,7 +535,7 @@ class SubscribeInteractionHandler: return mapping.get(state or "", state or "-") @staticmethod - def _format_subscribe_progress(subscribe: Subscribe) -> str: + def _format_subscribe_progress(subscribe: Any) -> str: """ 构造订阅的季和进度说明。 """ @@ -658,11 +672,10 @@ class SubscribeInteractionHandler: if not subscribe_ids: return False, "请输入订阅 ID,多个 ID 用空格分隔,或输入 all" - subscribeoper = SubscribeOper() missing = [] searched = [] for subscribe_id in subscribe_ids: - subscribe = subscribeoper.get(subscribe_id) + subscribe = self._repository.get(subscribe_id) if not subscribe: missing.append(str(subscribe_id)) continue @@ -696,17 +709,16 @@ class SubscribeInteractionHandler: if not subscribe_ids: return False, "请输入至少一个有效的订阅 ID" - subscribeoper = SubscribeOper() deleted = [] missing = [] for subscribe_id in subscribe_ids: - subscribe = subscribeoper.get(subscribe_id) + subscribe = self._repository.get(subscribe_id) if not subscribe: missing.append(str(subscribe_id)) continue deleted.append(subscribe.name) - subscribeoper.delete(subscribe_id) - MoviePilotServerHelper.sub_done_async( + self._repository.delete(subscribe_id) + self._report_deleted( { "media_source": subscribe.media_source, "media_id": subscribe.media_id, diff --git a/app/application/module.py b/app/application/module.py new file mode 100644 index 000000000..5fc55b8c3 --- /dev/null +++ b/app/application/module.py @@ -0,0 +1,59 @@ +"""宿主模块目录的应用层端口。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol + + +class ModuleRuntime(Protocol): + """声明入口层消费的模块目录能力。""" + + def __getattr__(self, name: str) -> Any: + """允许兼容门面访问既有模块管理方法。""" + + +ModuleRuntimeProvider = Callable[[], ModuleRuntime] + + +def _unconfigured_runtime() -> ModuleRuntime: + """拒绝在组合根装配前隐式创建模块管理器。""" + raise RuntimeError("宿主模块运行时尚未由启动组合根装配") + + +_runtime_provider: ModuleRuntimeProvider = _unconfigured_runtime + + +def configure_module_runtime(provider: ModuleRuntimeProvider) -> None: + """由启动组合根注册模块运行时实例提供器。""" + global _runtime_provider + _runtime_provider = provider + + +def get_module_manager() -> ModuleRuntime: + """返回当前组合根提供的模块目录能力。""" + return _runtime_provider() + + +class _ModuleRuntimeProxy(type): + """把历史 ``ModuleManager`` 调用转发到应用端口。""" + + def __getattr__(cls, name: str) -> Any: + """转发旧的类级静态调用。""" + return getattr(get_module_manager(), name) + + +class ModuleManager(metaclass=_ModuleRuntimeProxy): + """应用层兼容门面,实例调用返回组合根装配的模块管理器。""" + + def __new__(cls) -> ModuleRuntime: + """返回实际模块管理器,不复制运行态注册表。""" + return get_module_manager() + + +__all__ = [ + "ModuleManager", + "ModuleRuntime", + "configure_module_runtime", + "get_module_manager", +] diff --git a/app/application/notification.py b/app/application/notification.py index 2517f9a29..e62f980ed 100644 --- a/app/application/notification.py +++ b/app/application/notification.py @@ -1,6 +1,6 @@ from typing import Optional -from app.runtime.extensions.service_registry import ServiceBaseHelper +from app.application.service import ServiceBaseHelper from app.schemas.system import NotificationConf from app.schemas.system import ServiceInfo from app.schemas.types import ModuleType, SystemConfigKey diff --git a/app/application/plugin/routes.py b/app/application/plugin/routes.py index a02b08e6f..bec8fbd87 100644 --- a/app/application/plugin/routes.py +++ b/app/application/plugin/routes.py @@ -13,3 +13,7 @@ class DynamicRouteRegistry(Protocol): def remove(self, plugin_id: str) -> bool: """移除指定插件的全部动态路由。""" ... + + def clean(self, existing_paths: dict) -> None: + """清理重建过程中可能重复的受保护路由。""" + ... diff --git a/app/application/plugin/runtime.py b/app/application/plugin/runtime.py new file mode 100644 index 000000000..347573614 --- /dev/null +++ b/app/application/plugin/runtime.py @@ -0,0 +1,35 @@ +"""插件运行时目录的应用层端口。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol + + +class PluginRuntime(Protocol): + """声明入口层消费的插件宿主能力。""" + + def __getattr__(self, name: str) -> Any: + """允许兼容门面按既有 V3 方法名访问插件宿主能力。""" + + +PluginRuntimeProvider = Callable[[], PluginRuntime] + + +def _unconfigured_runtime() -> PluginRuntime: + """拒绝在启动组合根完成前隐式创建 Runtime 管理器。""" + raise RuntimeError("插件运行时尚未由启动组合根装配") + + +_runtime_provider: PluginRuntimeProvider = _unconfigured_runtime + + +def configure_plugin_runtime(provider: PluginRuntimeProvider) -> None: + """由启动组合根注册插件运行时实例提供器。""" + global _runtime_provider + _runtime_provider = provider + + +def get_plugin_manager() -> PluginRuntime: + """返回当前组合根提供的插件运行时能力。""" + return _runtime_provider() diff --git a/app/application/plugins.py b/app/application/plugins.py index 1b98dcc1f..b0dea907a 100644 --- a/app/application/plugins.py +++ b/app/application/plugins.py @@ -11,53 +11,25 @@ FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent from typing import Optional -from fastapi import FastAPI - -from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry -from app.application.security.access import verify_apikey, verify_token -from app.db.oper.systemconfig import SystemConfigOper -from app.runtime.config import settings -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.routes import DynamicRouteRegistry +from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import SystemConfigKey -PROTECTED_ROUTES = { - "/api/v1/openapi.json", - "/docs", - "/docs/oauth2-redirect", - "/redoc", -} -PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" - -# FastAPI 应用实例:由 factory 在创建应用后调用 register_api_app 注入。 -_api_app: Optional[FastAPI] = None +_route_registry: Optional[DynamicRouteRegistry] = None -def register_api_app(api_app: FastAPI) -> None: - """注入 FastAPI 应用实例(组合根在创建应用后调用)。""" - global _api_app - _api_app = api_app +def configure_plugin_routes(registry: DynamicRouteRegistry) -> None: + """由 HTTP 组合根注入动态插件路由适配器。""" + global _route_registry + _route_registry = registry -def get_api_app() -> FastAPI: - """返回已注入的 FastAPI 应用实例。""" - if _api_app is None: - raise RuntimeError("插件路由服务未初始化:请先调用 register_api_app 注入应用实例") - return _api_app - - -def _route_registry() -> FastAPIDynamicRouteRegistry: - """组装绑定当前 FastAPI 应用与插件管理器的动态路由适配器。""" - return FastAPIDynamicRouteRegistry( - app=get_api_app(), - plugin_ids=lambda: PluginManager().get_running_plugin_ids(), - plugin_apis=lambda plugin_id: PluginManager().get_plugin_apis(plugin_id), - verify_token=verify_token, - verify_apikey=verify_apikey, - prefix=PLUGIN_PREFIX, - protected_routes=PROTECTED_ROUTES, - log=logger, - ) +def _get_route_registry() -> DynamicRouteRegistry: + """返回已注入的动态插件路由端口。""" + if _route_registry is None: + raise RuntimeError("插件路由服务尚未由 HTTP 组合根配置") + return _route_registry def register_plugin_api(plugin_id: Optional[str] = None) -> None: @@ -83,7 +55,7 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None: 如果 action 为 "remove",plugin_id 必须是有效的插件 ID :param action: "add" 或 "remove",决定是添加还是移除路由 """ - _route_registry().update(plugin_id, action) + _get_route_registry().update(plugin_id, action) def _remove_routes(plugin_id: str) -> bool: @@ -92,7 +64,7 @@ def _remove_routes(plugin_id: str) -> bool: :param plugin_id: 插件 ID :return: 是否有路由被移除 """ - return _route_registry().remove(plugin_id) + return _get_route_registry().remove(plugin_id) def _clean_protected_routes(existing_paths: dict) -> None: @@ -100,7 +72,7 @@ def _clean_protected_routes(existing_paths: dict) -> None: 清理受保护的路由,防止在插件操作中被删除或重复添加 :param existing_paths: 当前应用的路由路径映射 """ - _route_registry().clean(existing_paths) + _get_route_registry().clean(existing_paths) def remove_plugin_from_folders(plugin_id: str): @@ -109,7 +81,7 @@ def remove_plugin_from_folders(plugin_id: str): :param plugin_id: 要移除的插件ID """ try: - config_oper = SystemConfigOper() + config_oper = get_configured_system_config() # 获取插件文件夹配置 folders = config_oper.get(SystemConfigKey.PluginFolders) or {} diff --git a/app/application/recognition.py b/app/application/recognition.py index 8a1848f85..5de029308 100644 --- a/app/application/recognition.py +++ b/app/application/recognition.py @@ -1,24 +1,28 @@ -from typing import Optional +from typing import Any, Optional -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.schemas.types import SystemConfigKey class RecognitionRuleService: """集中读取用户持久化的媒体识别规则,供启动层注入纯领域匹配器。""" - def __init__(self, systemconfig: Optional[SystemConfigOper] = None) -> None: + def __init__(self, systemconfig: Optional[Any] = None) -> None: """绑定系统配置访问器,测试可传入隔离替身。""" - self._systemconfig = systemconfig or SystemConfigOper() + self._systemconfig = systemconfig + + def _config(self) -> Any: + """惰性获取配置服务,避免引导阶段早于组合根装配。""" + return self._systemconfig or get_configured_system_config() def get_customization(self) -> object: """返回当前自定义占位符配置。""" - return self._systemconfig.get(SystemConfigKey.Customization) + return self._config().get(SystemConfigKey.Customization) def get_release_groups(self) -> object: """返回当前用户自定义制作组配置。""" - return self._systemconfig.get(SystemConfigKey.CustomReleaseGroups) + return self._config().get(SystemConfigKey.CustomReleaseGroups) def get_custom_words(self) -> object: """返回当前自定义识别词配置。""" - return self._systemconfig.get(SystemConfigKey.CustomIdentifiers) + return self._config().get(SystemConfigKey.CustomIdentifiers) diff --git a/app/application/rules.py b/app/application/rules.py index 87ac9ac30..88e85fa18 100644 --- a/app/application/rules.py +++ b/app/application/rules.py @@ -9,7 +9,7 @@ from typing import Dict, List, Optional from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, alphanums, Combine, nums, ParseResults from app.adapters.system import rust as rust_accel -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.domain.context import MediaInfo from app.schemas.rule import CustomRule from app.schemas.system import FilterRuleGroup @@ -22,7 +22,7 @@ class RuleHelper: @staticmethod def get_rule_groups() -> List[FilterRuleGroup]: """返回用户配置的全部过滤规则组。""" - rule_groups: List[dict] = SystemConfigOper().get( + rule_groups: List[dict] = get_configured_system_config().get( SystemConfigKey.UserFilterRuleGroups ) if not rule_groups: @@ -63,7 +63,7 @@ class RuleHelper: @staticmethod def get_custom_rules() -> List[CustomRule]: """返回用户配置的全部自定义过滤规则。""" - rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules) + rules: List[dict] = get_configured_system_config().get(SystemConfigKey.CustomFilterRules) if not rules: return [] return [CustomRule(**rule) for rule in rules] diff --git a/app/application/scheduling.py b/app/application/scheduling.py index 4d8e2dfec..c85ba2869 100644 --- a/app/application/scheduling.py +++ b/app/application/scheduling.py @@ -33,6 +33,14 @@ def get_scheduler() -> Any: return _scheduler_class() +class Scheduler: + """应用层调度器兼容门面,不直接导入顶层 Scheduler 实现。""" + + def __new__(cls) -> Any: + """返回组合根注册的调度器实例。""" + return get_scheduler() + + def list_scheduler_jobs() -> List[Any]: """列出运行时调度器的全部任务。""" return get_scheduler().list() diff --git a/app/application/security/access.py b/app/application/security/access.py deleted file mode 100644 index bf3f905ca..000000000 --- a/app/application/security/access.py +++ /dev/null @@ -1,471 +0,0 @@ -import base64 -import datetime -import hashlib -import hmac -import json -import os -import traceback -from datetime import timedelta -from typing import Any, Union, Annotated, Optional, Callable - -import bcrypt -import jwt -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad -from cryptography.fernet import Fernet -from fastapi import HTTPException, status, Security, Request, Response -from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie, HTTPBearer -from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.runtime.cache import cached -from app.runtime.config import settings -from app.runtime.log import logger - -BCRYPT_PASSWORD_MAX_BYTES = 72 -BCRYPT_ROUNDS = 12 -ALGORITHM = "HS256" -SuperuserTokenPayloadProvider = Callable[[], _SchemaTokenPayload] -_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None - - -class PasswordTooLongError(ValueError): - """密码的 UTF-8 字节长度超过 bcrypt 可安全处理的上限。""" - - -def _encode_bcrypt_password( - password: str, *, allow_legacy_truncation: bool = False -) -> bytes: - """编码 bcrypt 密码;仅验证既有哈希时允许按历史语义截断。""" - password_bytes = password.encode("utf-8") - if len(password_bytes) > BCRYPT_PASSWORD_MAX_BYTES: - if allow_legacy_truncation: - return password_bytes[:BCRYPT_PASSWORD_MAX_BYTES] - raise PasswordTooLongError( - f"密码 UTF-8 编码后不能超过 {BCRYPT_PASSWORD_MAX_BYTES} 字节" - ) - return password_bytes - - -def set_superuser_token_payload_provider( - provider: SuperuserTokenPayloadProvider, -) -> None: - """注入 API 密钥认证所需的超级用户载荷提供器。""" - global _superuser_token_payload_provider - _superuser_token_payload_provider = provider - -# OAuth2PasswordBearer 用于 JWT Token 认证 -oauth2_scheme_manual_error = OAuth2PasswordBearer( - auto_error=False, # 禁用自动错误处理,用以支持API令牌鉴权 - tokenUrl=f"{settings.API_V1_STR}/login/access-token" -) - -# RESOURCE TOKEN 通过 Cookie 认证 -resource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name="resource_token_cookie") - -# API TOKEN 通过 QUERY 认证 -api_token_query = APIKeyQuery(name="token", auto_error=False, scheme_name="api_token_query") - -# API KEY 通过 Header 认证 -api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="api_key_header") - -# API KEY 通过 QUERY 认证 -api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query") - -# OpenAI compatible Bearer Token 认证 -openai_bearer_scheme = HTTPBearer(auto_error=False) - -# Anthropic compatible API Key 认证 -anthropic_api_key_header = APIKeyHeader(name="x-api-key", auto_error=False, scheme_name="anthropic_api_key_header") - - -def __get_api_token( - token_query: Annotated[str | None, Security(api_token_query)] = None -) -> str | None: - """ - 从 URL 查询参数中获取 API Token - :param token_query: 从 URL 中的 `token` 查询参数获取 API Token - :return: 返回获取到的 API Token,若无则返回 None - """ - return token_query - - -def __get_api_key( - key_query: Annotated[str | None, Security(api_key_query)] = None, - key_header: Annotated[str | None, Security(api_key_header)] = None -) -> str | None: - """ - 从 URL 查询参数或请求头部获取 API Key,优先使用请求头 - :param key_query: URL 中的 `apikey` 查询参数 - :param key_header: 请求头中的 `X-API-KEY` 参数 - :return: 返回从 URL 或请求头中获取的 API Key,若无则返回 None - """ - return key_header or key_query # 首选请求头 - - -@cached(maxsize=1, ttl=600) -def __create_superuser_token_payload() -> _SchemaTokenPayload: - """ - 创建管理员用户的TokenPayload - - :return: 管理员TokenPayload - """ - if not _superuser_token_payload_provider: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="认证服务尚未初始化", - ) - return _superuser_token_payload_provider() - - -def create_access_token( - userid: Union[str, Any], - username: str, - super_user: Optional[bool] = False, - expires_delta: Optional[timedelta] = None, - level: Optional[int] = 1, - purpose: Optional[str] = "authentication" -) -> str: - """ - 创建 JWT 访问令牌,包含用户 ID、用户名、是否为超级用户以及权限等级 - :param userid: 用户的唯一标识符,通常是字符串或整数 - :param username: 用户名,用于标识用户的账户名 - :param super_user: 是否为超级用户,默认值为 False - :param expires_delta: 令牌的有效期时长,如果不提供则根据用途使用默认过期时间 - :param level: 用户的权限级别,默认为 1 - :param purpose: 令牌的用途,"authentication" 或 "resource" - :return: 编码后的 JWT 令牌字符串 - :raises ValueError: 如果 expires_delta 为负数 - """ - if purpose == "resource": - default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS) - secret_key = settings.RESOURCE_SECRET_KEY - else: - default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - secret_key = settings.SECRET_KEY - - if expires_delta is not None: - if expires_delta.total_seconds() <= 0: - raise ValueError("过期时间必须为正数") - expire = datetime.datetime.now(datetime.UTC) + expires_delta - else: - expire = datetime.datetime.now(datetime.UTC) + default_expire - - to_encode = { - "exp": expire, - "iat": datetime.datetime.now(datetime.UTC), - "sub": str(userid), - "username": username, - "super_user": super_user, - "level": level, - "purpose": purpose - } - - encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) - return encoded_jwt - - -def set_or_refresh_resource_token_cookie( - request: Request, response: Response, payload: _SchemaTokenPayload -) -> None: - """ - 设置资源令牌 Cookie - :param request: 包含请求相关的上下文数据 - :param response: 用于在服务器响应时设置 Cookie - :param payload: 已通过身份验证的 TokenPayload 对象 - """ - resource_token = request.cookies.get(settings.PROJECT_NAME) - - if resource_token: - # 检查令牌剩余时间 - try: - decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM]) - exp = decoded_token.get("exp") - if exp: - remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC) - # 根据剩余时长提前刷新令牌 - if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)): - raise jwt.ExpiredSignatureError - expected_claims = { - "sub": str(payload.sub), - "username": payload.username, - "super_user": payload.super_user, - "level": payload.level, - "purpose": "resource", - } - if any(decoded_token.get(claim) != value for claim, value in expected_claims.items()): - raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配") - except jwt.PyJWTError: - logger.debug(f"Token error occurred. refreshing token") - except Exception as e: - logger.debug(f"Unexpected error occurred while decoding token: {e}") - else: - # 如果令牌有效且没有即将过期,则不需要刷新 - return - - # 创建新的资源访问令牌 - resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS) - resource_token = create_access_token( - userid=payload.sub, - username=payload.username, - super_user=payload.super_user, - expires_delta=resource_token_expires, - level=payload.level, - purpose="resource" - ) - - # 判断请求是否为 HTTPS:直连协议为 https,或经反向代理转发时携带 X-Forwarded-Proto: https。 - # 无法确认为明文 HTTP 时按 fail-safe 默认设置 secure=True,避免代理终止 HTTPS 后以 HTTP 转发导致 Cookie 明文传输。 - is_https = ( - request.url.scheme == "https" - or request.headers.get("x-forwarded-proto", "").lower() == "https" - ) - - # 设置会话级别的 HttpOnly Cookie - response.set_cookie( - key=settings.PROJECT_NAME, - value=resource_token, - httponly=True, - secure=is_https, # 根据当前请求协议(含反向代理转发标识)设置 secure 属性 - samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性 - ) - - -def __verify_token(token: str, purpose: Optional[str] = "authentication") -> _SchemaTokenPayload: - """ - 使用 JWT Token 进行身份认证并解析 Token 的内容 - :param token: JWT 令牌 - :param purpose: 期望的令牌用途,默认为 "authentication" - :return: 包含用户身份信息的 Token 负载数据 - :raises HTTPException: 如果令牌无效或用途不匹配 - """ - try: - if purpose == "resource": - secret_key = settings.RESOURCE_SECRET_KEY - else: - secret_key = settings.SECRET_KEY - - if not token: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"{purpose} token not found" - ) - - payload = jwt.decode( - token, secret_key, algorithms=[ALGORITHM] - ) - - token_payload = _SchemaTokenPayload(**payload) - - if token_payload.purpose != purpose: - raise jwt.InvalidTokenError("令牌用途不匹配") - - return _SchemaTokenPayload(**payload) - except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="token校验不通过", - ) - - -def verify_token( - request: Request, - response: Response, - jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)], - api_key: Annotated[str | None, Security(__get_api_key)], - api_token: Annotated[str | None, Security(__get_api_token)], -) -> _SchemaTokenPayload: - """ - 验证 JWT 令牌并自动处理 resource_token 写入 - - 如果缺少JWT令牌再尝试用API令牌鉴权 - - :param request: 请求对象,用于访问 Cookie 和请求信息 - :param response: 响应对象,用于设置 Cookie - :param jwt_token: 从 Authorization 头部获取的 JWT 令牌 - :param api_key: 从 查询参数`apikey` 或 请求头`X-API-KEY` 获取 API Token - :param api_token: 从 查询参数`token` 获取 API Token - :return: 解析后的 TokenPayload - :raises HTTPException: 如果令牌无效或用途不匹配 - """ - if jwt_token: - # 验证并解析 JWT 认证令牌 - payload = __verify_token(token=jwt_token, purpose="authentication") - - # 如果没有 resource_token,生成并写入到 Cookie - set_or_refresh_resource_token_cookie(request, response, payload) - - return payload - elif api_key: - verify_apikey(api_key) - return __create_superuser_token_payload() - elif api_token: - verify_apitoken(api_token) - return __create_superuser_token_payload() - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) - - -def verify_resource_token( - resource_token: Annotated[str, Security(resource_token_cookie)] -) -> _SchemaTokenPayload: - """ - 验证资源访问令牌(从 Cookie 中获取) - :param resource_token: 从 Cookie 中获取的资源访问令牌 - :return: 解析后的 TokenPayload - :raises HTTPException: 如果资源访问令牌无效 - """ - # 验证并解析资源访问令牌 - return __verify_token(token=resource_token, purpose="resource") - - -def __verify_key(key: str | None, expected_key: str, key_type: str) -> str: - """ - 通用的 API Key 或 Token 验证函数 - :param key: 从请求中获取的 API Key 或 Token - :param expected_key: 系统配置中的期望值,用于验证的 API Key 或 Token - :param key_type: 键的类型(例如 "API_KEY" 或 "API_TOKEN"),用于错误消息 - :return: 返回校验通过的 API Key 或 Token - :raises HTTPException: 如果校验不通过,抛出 401 错误 - """ - if not key or key != expected_key: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"{key_type} 校验不通过" - ) - return key - - -def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str: - """ - 使用 API Token 进行受信第三方集成认证。 - - 校验值来自 settings.API_TOKEN;通过后只确认集成凭据有效,不生成 per-user 权限上下文。 - :param token: API Token,从 URL 查询参数中获取 token=xxx - :return: 返回校验通过的 API Token - """ - return __verify_key(token, settings.API_TOKEN, "token") - - -def verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str: - """ - 使用 API Key 形式进行受信第三方集成认证。 - - 请求字段名兼容 API Key,实际校验值来自 settings.API_TOKEN,不生成 per-user 权限上下文。 - :param apikey: API Key,从 URL 查询参数中获取 apikey=xxx,或请求头中获取 X-API-KEY=xxx - :return: 返回校验通过的 API Key - """ - return __verify_key(apikey, settings.API_TOKEN, "apikey") - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """验证既有 bcrypt 哈希,并保留超长历史密码的截断语义。""" - try: - return bcrypt.checkpw( - _encode_bcrypt_password( - plain_password, allow_legacy_truncation=True - ), - hashed_password.encode("ascii"), - ) - except (UnicodeEncodeError, ValueError): - return False - - -def get_password_hash(password: str) -> str: - """使用 $2b$ 前缀和 cost 12 生成可持久化的 bcrypt 密码哈希。""" - return bcrypt.hashpw( - _encode_bcrypt_password(password), - bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"), - ).decode("ascii") - - -def decrypt(data: bytes, key: bytes) -> Optional[bytes]: - """ - 解密二进制数据 - """ - fernet = Fernet(key) - try: - return fernet.decrypt(data) - except Exception as e: - logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}") - return None - - -def encrypt_message(message: str, key: bytes) -> str: - """ - 使用给定的key对消息进行加密,并返回加密后的字符串 - """ - f = Fernet(key) - encrypted_message = f.encrypt(message.encode()) - return encrypted_message.decode() - - -def hash_sha256(message: str) -> str: - """ - 对字符串做hash运算 - """ - return hashlib.sha256(message.encode()).hexdigest() - - -def aes_decrypt(data: str, key: str) -> str: - """ - AES解密 - """ - if not data: - return "" - data = base64.b64decode(data) - iv = data[:16] - encrypted = data[16:] - # 使用AES-256-CBC解密 - cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv) - result = cipher.decrypt(encrypted) - # 去除填充 - padding = result[-1] - if padding < 1 or padding > AES.block_size: - return "" - result = result[:-padding] - return result.decode('utf-8') - - -def aes_encrypt(data: str, key: str) -> str: - """ - AES加密 - """ - if not data: - return "" - # 使用AES-256-CBC加密 - cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC) - # 填充 - padding = AES.block_size - len(data) % AES.block_size - data += chr(padding) * padding - result = cipher.encrypt(data.encode('utf-8')) - # 使用base64编码 - return base64.b64encode(cipher.iv + result).decode('utf-8') - - -def nexusphp_encrypt(data_str: str, key: bytes) -> str: - """ - NexusPHP加密 - """ - # 生成16字节长的随机字符串 - iv = os.urandom(16) - # 对向量进行 Base64 编码 - iv_base64 = base64.b64encode(iv) - # 加密数据 - cipher = AES.new(key, AES.MODE_CBC, iv) - ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size)) - ciphertext_base64 = base64.b64encode(ciphertext) - # 对向量的字符串表示进行签名 - mac = hmac.new(key, msg=iv_base64 + ciphertext_base64, digestmod=hashlib.sha256).hexdigest() - # 构造 JSON 字符串 - json_str = json.dumps({ - 'iv': iv_base64.decode(), - 'value': ciphertext_base64.decode(), - 'mac': mac, - 'tag': '' - }) - - # 对 JSON 字符串进行 Base64 编码 - return base64.b64encode(json_str.encode()).decode() diff --git a/app/application/security/auth.py b/app/application/security/auth.py index 4cbed36b8..ce867de43 100644 --- a/app/application/security/auth.py +++ b/app/application/security/auth.py @@ -2,17 +2,12 @@ import secrets import threading import time from datetime import timedelta -from typing import Any, Optional - -from fastapi import HTTPException, status +from typing import Any, Optional, Protocol from app.schemas.token import Token as _SchemaToken from app.schemas.token import TokenPayload as _SchemaTokenPayload -from app.application.security import access as security +from app.application.security.token import create_access_token from app.runtime.config import settings -from app.db.models.user import User -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.user import UserOper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.schemas.types import SystemConfigKey from app.foundation.singleton import Singleton @@ -119,49 +114,128 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]: return AuthTicketStore().consume(ticket) -def build_superuser_token_payload() -> _SchemaTokenPayload: - """从持久化用户和站点认证状态构造超级用户令牌载荷。""" - user = UserOper().get_by_name(settings.SUPERUSER) - if not user or not user.is_superuser: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户权限不足", - ) - return _SchemaTokenPayload( - sub=user.id, - username=user.name, - super_user=user.is_superuser, - level=SitesHelper().auth_level, - purpose="authentication", - ) +class AuthUser(Protocol): + """认证服务需要的最小用户投影。""" + + id: int + name: str + is_active: bool + is_superuser: bool + avatar: Optional[str] + permissions: Optional[dict] -def build_token_response(user: User) -> _SchemaToken: - """ - 使用系统统一逻辑构造登录 Token 响应。 +class AuthUserRepository(Protocol): + """认证服务的用户数据端口。""" - :param user: 已认证的本地用户 - :return: 标准 Token 响应 - """ - level = SitesHelper().auth_level - show_wizard = ( - not SystemConfigOper().get(SystemConfigKey.SetupWizardState) - and not settings.ADVANCED_MODE - ) - return _SchemaToken( - access_token=security.create_access_token( - userid=user.id, + def get_by_name(self, name: str) -> Optional[AuthUser]: + """按用户名查询用户。""" + + def get_by_id(self, user_id: int) -> Optional[AuthUser]: + """按 ID 查询用户。""" + + +class AuthPasskeyRepository(Protocol): + """认证提供方查询端口。""" + + def list(self) -> list[Any]: + """返回已启用的 PassKey。""" + + +class AuthConfigRepository(Protocol): + """认证配置读取端口。""" + + def get(self, key: Any) -> Any: + """读取配置值。""" + + +class AuthService: + """认证应用服务,编排用户、配置和 PassKey 端口。""" + + def __init__( + self, + users: AuthUserRepository, + config: AuthConfigRepository, + passkeys: AuthPasskeyRepository, + ) -> None: + """注入认证所需的数据端口。""" + self._users = users + self._config = config + self._passkeys = passkeys + + def get_user_by_id(self, user_id: int) -> Optional[AuthUser]: + """按 ID 查询本地用户。""" + return self._users.get_by_id(user_id) + + def has_passkey(self) -> bool: + """判断系统是否已有 PassKey。""" + return bool(self._passkeys.list()) + + def build_superuser_token_payload(self) -> _SchemaTokenPayload: + """从持久化用户和站点认证状态构造超级用户令牌载荷。""" + user = self._users.get_by_name(settings.SUPERUSER) + if not user or not user.is_superuser: + raise PermissionError("用户权限不足") + return _SchemaTokenPayload( + sub=user.id, username=user.name, super_user=user.is_superuser, - expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + level=SitesHelper().auth_level, + purpose="authentication", + ) + + def build_token_response(self, user: AuthUser) -> _SchemaToken: + """使用统一逻辑构造登录 Token 响应。""" + level = SitesHelper().auth_level + show_wizard = ( + not self._config.get(SystemConfigKey.SetupWizardState) + and not settings.ADVANCED_MODE + ) + return _SchemaToken( + access_token=create_access_token( + userid=user.id, + username=user.name, + super_user=user.is_superuser, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + level=level, + ), + token_type="bearer", + super_user=user.is_superuser, + user_id=user.id, + user_name=user.name, + avatar=user.avatar, level=level, - ), - token_type="bearer", - super_user=user.is_superuser, - user_id=user.id, - user_name=user.name, - avatar=user.avatar, - level=level, - permissions=user.permissions or {}, - wizard=show_wizard, - ) + permissions=user.permissions or {}, + wizard=show_wizard, + ) + + +_configured_auth_service: AuthService | None = None + + +def configure_auth_service(service: AuthService) -> None: + """由启动组合根登记认证应用服务。""" + global _configured_auth_service + _configured_auth_service = service + + +def _get_auth_service() -> AuthService: + """返回启动阶段登记的认证应用服务。""" + if _configured_auth_service is None: + raise RuntimeError("认证服务尚未配置") + return _configured_auth_service + + +def get_configured_auth_service() -> AuthService: + """返回启动阶段登记的认证服务。""" + return _get_auth_service() + + +def build_superuser_token_payload() -> _SchemaTokenPayload: + """使用启动组合根注入的认证服务构造超级用户令牌载荷。""" + return _get_auth_service().build_superuser_token_payload() + + +def build_token_response(user: AuthUser) -> _SchemaToken: + """使用启动组合根注入的认证服务构造登录 Token 响应。""" + return _get_auth_service().build_token_response(user) diff --git a/app/application/security/passkeys.py b/app/application/security/passkeys.py new file mode 100644 index 000000000..6120a5fc2 --- /dev/null +++ b/app/application/security/passkeys.py @@ -0,0 +1,75 @@ +"""PassKey 认证凭证应用服务。""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol + + +class PasskeyRepository(Protocol): + """PassKey 用例需要的最小同步数据端口。""" + + def list(self) -> list[Any]: + """列出全部启用凭证。""" + + def list_by_user_id(self, user_id: int) -> list[Any]: + """列出指定用户凭证。""" + + def get_by_credential_id(self, credential_id: str) -> Optional[Any]: + """按凭证 ID 查找凭证。""" + + def create(self, payload: dict[str, Any]) -> Any: + """创建凭证。""" + + def update_last_used(self, passkey: Any, sign_count: int) -> bool: + """更新凭证使用计数。""" + + def delete_by_id(self, passkey_id: int, user_id: int) -> bool: + """删除用户凭证。""" + + +class PasskeyService: + """编排 PassKey 凭证生命周期。""" + + def __init__(self, repository: PasskeyRepository) -> None: + """注入 PassKey 数据端口。""" + self._repository = repository + + def list(self) -> list[Any]: + """列出全部启用凭证。""" + 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 get_by_credential_id(self, credential_id: str) -> Optional[Any]: + """按凭证 ID 查找凭证。""" + return self._repository.get_by_credential_id(credential_id) + + def create(self, payload: dict[str, Any]) -> Any: + """创建凭证。""" + return self._repository.create(payload) + + def update_last_used(self, passkey: Any, sign_count: int) -> bool: + """更新凭证使用计数。""" + return self._repository.update_last_used(passkey, sign_count) + + def delete_by_id(self, passkey_id: int, user_id: int) -> bool: + """删除用户凭证。""" + return self._repository.delete_by_id(passkey_id, user_id) + + +_configured_passkey_service: PasskeyService | None = None + + +def configure_passkey_service(service: PasskeyService) -> None: + """由启动组合根登记 PassKey 应用服务。""" + global _configured_passkey_service + _configured_passkey_service = service + + +def get_configured_passkey_service() -> PasskeyService: + """返回启动阶段登记的 PassKey 应用服务。""" + if _configured_passkey_service is None: + raise RuntimeError("PassKey 服务尚未配置") + return _configured_passkey_service diff --git a/app/application/security/token.py b/app/application/security/token.py new file mode 100644 index 000000000..77f098180 --- /dev/null +++ b/app/application/security/token.py @@ -0,0 +1,201 @@ +"""与传输框架无关的令牌、密码和对称加密能力。""" + +import base64 +import datetime +import hashlib +import hmac +import json +import os +import traceback +from datetime import timedelta +from typing import Any, Optional, Union + +import bcrypt +import jwt +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad +from cryptography.fernet import Fernet + +from app.runtime.config import settings +from app.runtime.log import logger +from app.schemas.token import TokenPayload + +BCRYPT_PASSWORD_MAX_BYTES = 72 +BCRYPT_ROUNDS = 12 +ALGORITHM = "HS256" + + +class PasswordTooLongError(ValueError): + """密码的 UTF-8 字节长度超过 bcrypt 可安全处理的上限。""" + + +class TokenValidationError(ValueError): + """令牌缺失、签名无效或用途不符合调用方要求。""" + + +def _encode_bcrypt_password( + password: str, + *, + allow_legacy_truncation: bool = False, +) -> bytes: + """编码 bcrypt 密码;仅验证既有哈希时允许按历史语义截断。""" + password_bytes = password.encode("utf-8") + if len(password_bytes) > BCRYPT_PASSWORD_MAX_BYTES: + if allow_legacy_truncation: + return password_bytes[:BCRYPT_PASSWORD_MAX_BYTES] + raise PasswordTooLongError( + f"密码 UTF-8 编码后不能超过 {BCRYPT_PASSWORD_MAX_BYTES} 字节" + ) + return password_bytes + + +def create_access_token( + userid: Union[str, Any], + username: str, + super_user: Optional[bool] = False, + expires_delta: Optional[timedelta] = None, + level: Optional[int] = 1, + purpose: Optional[str] = "authentication", +) -> str: + """创建带身份、权限等级和用途声明的 JWT 访问令牌。""" + if purpose == "resource": + default_expire = timedelta( + seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS + ) + secret_key = settings.RESOURCE_SECRET_KEY + else: + default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + secret_key = settings.SECRET_KEY + + if expires_delta is not None: + if expires_delta.total_seconds() <= 0: + raise ValueError("过期时间必须为正数") + expire = datetime.datetime.now(datetime.UTC) + expires_delta + else: + expire = datetime.datetime.now(datetime.UTC) + default_expire + + now = datetime.datetime.now(datetime.UTC) + payload = { + "exp": expire, + "iat": now, + "sub": str(userid), + "username": username, + "super_user": super_user, + "level": level, + "purpose": purpose, + } + return jwt.encode(payload, secret_key, algorithm=ALGORITHM) + + +def decode_access_token( + token: str | None, + purpose: str = "authentication", +) -> TokenPayload: + """校验 JWT 签名和用途并返回框架无关的令牌载荷。""" + if not token: + raise TokenValidationError(f"{purpose} token not found") + secret_key = ( + settings.RESOURCE_SECRET_KEY + if purpose == "resource" + else settings.SECRET_KEY + ) + try: + payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) + token_payload = TokenPayload(**payload) + if token_payload.purpose != purpose: + raise jwt.InvalidTokenError("令牌用途不匹配") + return token_payload + except ( + jwt.DecodeError, + jwt.InvalidTokenError, + jwt.ImmatureSignatureError, + ) as error: + raise TokenValidationError("token校验不通过") from error + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证既有 bcrypt 哈希,并保留超长历史密码的截断语义。""" + try: + return bcrypt.checkpw( + _encode_bcrypt_password( + plain_password, + allow_legacy_truncation=True, + ), + hashed_password.encode("ascii"), + ) + except (UnicodeEncodeError, ValueError): + return False + + +def get_password_hash(password: str) -> str: + """使用 ``$2b$`` 前缀和 cost 12 生成可持久化的 bcrypt 哈希。""" + return bcrypt.hashpw( + _encode_bcrypt_password(password), + bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"), + ).decode("ascii") + + +def decrypt(data: bytes, key: bytes) -> Optional[bytes]: + """使用 Fernet 解密二进制数据,失败时记录诊断并返回空值。""" + try: + return Fernet(key).decrypt(data) + except Exception as error: + logger.error(f"解密失败:{str(error)} - {traceback.format_exc()}") + return None + + +def encrypt_message(message: str, key: bytes) -> str: + """使用 Fernet 加密文本并返回可传输字符串。""" + return Fernet(key).encrypt(message.encode()).decode() + + +def hash_sha256(message: str) -> str: + """返回文本的 SHA-256 十六进制摘要。""" + return hashlib.sha256(message.encode()).hexdigest() + + +def aes_decrypt(data: str, key: str) -> str: + """按历史 AES-256-CBC 合同解密 Base64 文本。""" + if not data: + return "" + raw_data = base64.b64decode(data) + iv = raw_data[:16] + encrypted = raw_data[16:] + cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv) + result = cipher.decrypt(encrypted) + padding = result[-1] + if padding < 1 or padding > AES.block_size: + return "" + return result[:-padding].decode("utf-8") + + +def aes_encrypt(data: str, key: str) -> str: + """按历史 AES-256-CBC 合同加密文本并返回 Base64 字符串。""" + if not data: + return "" + cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC) + padding = AES.block_size - len(data) % AES.block_size + padded = data + chr(padding) * padding + result = cipher.encrypt(padded.encode("utf-8")) + return base64.b64encode(cipher.iv + result).decode("utf-8") + + +def nexusphp_encrypt(data_str: str, key: bytes) -> str: + """生成 NexusPHP 兼容的 AES-CBC 加密载荷。""" + iv = os.urandom(16) + iv_base64 = base64.b64encode(iv) + cipher = AES.new(key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size)) + ciphertext_base64 = base64.b64encode(ciphertext) + mac = hmac.new( + key, + msg=iv_base64 + ciphertext_base64, + digestmod=hashlib.sha256, + ).hexdigest() + payload = json.dumps({ + "iv": iv_base64.decode(), + "value": ciphertext_base64.decode(), + "mac": mac, + "tag": "", + }) + return base64.b64encode(payload.encode()).decode() diff --git a/app/application/security/user.py b/app/application/security/user.py new file mode 100644 index 000000000..52cb6bd5a --- /dev/null +++ b/app/application/security/user.py @@ -0,0 +1,108 @@ +"""用户管理用例。 + +该模块承接用户端点需要的异步用户操作。具体数据库访问由请求组合根注入, +避免 API 层同时承担 HTTP 编排和 ORM 适配职责。 +""" + +from collections.abc import Callable +from typing import Any, Protocol + + +class UserRepository(Protocol): + """用户用例所需的最小异步数据端口。""" + + async def async_list(self) -> list[Any]: + """返回全部用户。""" + + async def async_get_by_name(self, name: str) -> Any | None: + """按用户名返回用户。""" + + async def async_get_by_id(self, user_id: int) -> Any | None: + """按用户 ID 返回用户。""" + + async def async_create(self, payload: dict[str, Any]) -> Any | None: + """创建用户并返回持久化对象。""" + + async def async_update(self, user_id: int, payload: dict[str, Any]) -> Any | None: + """更新用户并返回原用户对象。""" + + async def async_delete(self, user_id: int) -> None: + """删除用户。""" + + async def async_update_otp_by_name(self, name: str, otp: bool, secret: str) -> None: + """更新用户 OTP 状态。""" + + +class UserService: + """用户管理应用服务。""" + + def __init__(self, repository: UserRepository) -> None: + """创建用户服务。""" + self._repository = repository + + async def list(self) -> list[Any]: + """返回用户列表。""" + return await self._repository.async_list() + + async def get_by_name(self, name: str) -> Any | None: + """按用户名查询用户。""" + return await self._repository.async_get_by_name(name) + + async def get_by_id(self, user_id: int) -> Any | None: + """按用户 ID 查询用户。""" + return await self._repository.async_get_by_id(user_id) + + async def create(self, payload: dict[str, Any]) -> Any | None: + """创建用户。""" + return await self._repository.async_create(payload) + + async def update(self, user_id: int, payload: dict[str, Any]) -> Any | None: + """更新用户。""" + return await self._repository.async_update(user_id, payload) + + async def delete(self, user_id: int) -> None: + """删除用户。""" + await self._repository.async_delete(user_id) + + async def update_otp(self, name: str, otp: bool, secret: str) -> None: + """更新用户 OTP 状态。""" + await self._repository.async_update_otp_by_name(name, otp, secret) + + +_configured_user_id_lookup: Callable[[int], Any | None] | None = None +_configured_user_name_lookup: Callable[[str], Any | None] | None = None +_configured_user_channel_lookup: Callable[..., str | None] | None = None + + +def configure_user_lookups( + by_id: Callable[[int], Any | None], + by_name: Callable[[str], Any | None], + by_channel: Callable[..., str | None], +) -> None: + """由启动组合根登记 ID、用户名和渠道身份查询能力。""" + global _configured_user_id_lookup, _configured_user_name_lookup + global _configured_user_channel_lookup + _configured_user_id_lookup = by_id + _configured_user_name_lookup = by_name + _configured_user_channel_lookup = by_channel + + +def get_configured_user_id_lookup() -> Callable[[int], Any | None]: + """返回启动阶段登记的按 ID 用户查询函数。""" + if _configured_user_id_lookup is None: + raise RuntimeError("按 ID 的用户查询能力尚未配置") + return _configured_user_id_lookup + + +def get_configured_user_name_lookup() -> Callable[[str], Any | None]: + """返回启动阶段登记的按用户名查询函数。""" + if _configured_user_name_lookup is None: + raise RuntimeError("按用户名的用户查询能力尚未配置") + return _configured_user_name_lookup + + +def get_configured_user_channel_lookup() -> Callable[..., str | None]: + """返回启动阶段登记的渠道身份到用户名查询函数。""" + if _configured_user_channel_lookup is None: + raise RuntimeError("渠道用户查询能力尚未配置") + return _configured_user_channel_lookup diff --git a/app/application/security/userconfig.py b/app/application/security/userconfig.py new file mode 100644 index 000000000..09617ee53 --- /dev/null +++ b/app/application/security/userconfig.py @@ -0,0 +1,47 @@ +"""用户个性化配置应用服务。""" + +from __future__ import annotations + +from typing import Any, Protocol + + +class UserConfigurationRepository(Protocol): + """用户配置数据端口。""" + + def get(self, username: str, key: str) -> Any: + """读取用户配置。""" + + def set(self, username: str, key: str, value: Any) -> Any: + """写入用户配置。""" + + +class UserConfigurationService: + """编排用户个性化配置读写。""" + + def __init__(self, repository: UserConfigurationRepository) -> None: + """注入用户配置数据端口。""" + self._repository = repository + + def get(self, username: str, key: str) -> Any: + """读取用户配置。""" + return self._repository.get(username=username, key=key) + + def set(self, username: str, key: str, value: Any) -> Any: + """写入用户配置。""" + return self._repository.set(username=username, key=key, value=value) + + +_configured_user_configuration: UserConfigurationService | None = None + + +def configure_user_configuration(service: UserConfigurationService) -> None: + """由启动组合根登记用户配置服务。""" + global _configured_user_configuration + _configured_user_configuration = service + + +def get_configured_user_configuration() -> UserConfigurationService: + """返回启动阶段登记的用户配置服务。""" + if _configured_user_configuration is None: + raise RuntimeError("用户配置服务尚未配置") + return _configured_user_configuration diff --git a/app/application/servarr.py b/app/application/servarr.py new file mode 100644 index 000000000..5cd318c27 --- /dev/null +++ b/app/application/servarr.py @@ -0,0 +1,154 @@ +"""Servarr 兼容接口使用的订阅投影和数据用例。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Protocol + +from app.schemas.types import MediaSource + + +@dataclass(frozen=True, slots=True) +class ServarrSubscription: + """隔离 Servarr 端点与订阅 ORM 模型的稳定投影。""" + + id: int + name: Optional[str] + year: Optional[str] + type: Optional[str] + season: Optional[int] + poster: Optional[str] + media_source: Optional[str] + media_id: Optional[str] + + +class ServarrAsyncSubscriptionRepository(Protocol): + """Servarr 异步订阅用例需要的最小仓储端口。""" + + async def async_list(self) -> list[Any]: + """读取全部订阅。""" + ... + + async def async_get(self, subscribe_id: int) -> Optional[Any]: + """按主键读取订阅。""" + ... + + async def async_list_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + music_type: Optional[str] = None, + ) -> list[Any]: + """按媒体身份读取订阅。""" + ... + + async def async_exists( + self, + media_source: MediaSource, + media_id: str, + season: Optional[int] = None, + episode_group: Optional[str] = None, + music_type: Optional[str] = None, + ) -> Optional[Any]: + """按媒体身份读取命中的订阅。""" + ... + + async def async_delete(self, subscribe_id: int) -> None: + """按主键删除订阅。""" + ... + + +class ServarrSyncSubscriptionRepository(Protocol): + """Servarr 同步 lookup 用例需要的最小仓储端口。""" + + def list_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + music_type: Optional[str] = None, + ) -> list[Any]: + """按媒体身份读取订阅。""" + ... + + +class ServarrSubscriptionService: + """提供 Servarr 路由所需的订阅查询、查重和删除能力。""" + + def __init__( + self, + *, + async_repository: ServarrAsyncSubscriptionRepository, + sync_repository: ServarrSyncSubscriptionRepository, + ) -> None: + """保存请求级同步和异步订阅仓储。""" + self._async_repository = async_repository + self._sync_repository = sync_repository + + async def list(self) -> list[ServarrSubscription]: + """读取全部订阅并转换为脱离 ORM 会话的投影。""" + return [self._project(record) for record in await self._async_repository.async_list()] + + async def get(self, subscribe_id: int) -> Optional[ServarrSubscription]: + """按主键读取订阅投影。""" + record = await self._async_repository.async_get(subscribe_id) + return self._project(record) if record else None + + async def list_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + ) -> list[ServarrSubscription]: + """异步按媒体身份读取订阅投影。""" + records = await self._async_repository.async_list_by_media_identity( + media_source=media_source, + media_id=media_id, + ) + return [self._project(record) for record in records] + + def list_by_media_identity_sync( + self, + media_source: MediaSource, + media_id: str, + ) -> list[ServarrSubscription]: + """同步按媒体身份读取订阅投影。""" + records = self._sync_repository.list_by_media_identity( + media_source=media_source, + media_id=media_id, + ) + return [self._project(record) for record in records] + + async def exists( + self, + *, + media_source: MediaSource, + media_id: str, + season: Optional[int] = None, + ) -> bool: + """判断指定媒体身份和季是否已有订阅。""" + record = await self._async_repository.async_exists( + media_source=media_source, + media_id=media_id, + season=season, + ) + return record is not None + + async def delete(self, subscribe_id: int) -> bool: + """删除存在的订阅并报告是否实际命中。""" + if not await self._async_repository.async_get(subscribe_id): + return False + await self._async_repository.async_delete(subscribe_id) + return True + + @staticmethod + def _project(record: Any) -> ServarrSubscription: + """从数据库记录复制 Servarr 路由所需的最小字段。""" + return ServarrSubscription( + id=record.id, + name=getattr(record, "name", None), + year=getattr(record, "year", None), + type=getattr(record, "type", None), + season=getattr(record, "season", None), + poster=getattr(record, "poster", None), + media_source=getattr(record, "media_source", None), + media_id=getattr(record, "media_id", None), + ) diff --git a/app/application/service.py b/app/application/service.py new file mode 100644 index 000000000..e902b9f7e --- /dev/null +++ b/app/application/service.py @@ -0,0 +1,124 @@ +"""下载器、媒体服务器和通知服务的应用层目录端口。""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar + +from app.schemas.system import ServiceInfo +from app.schemas.types import ModuleType, SystemConfigKey + +TConf = TypeVar("TConf") +ServiceConfigLoader = Callable[[SystemConfigKey, Type[Any]], list[Any]] +RunningModuleLoader = Callable[[ModuleType], list[Any]] + + +def _unconfigured_configs( + _config_key: SystemConfigKey, + _conf_type: Type[Any], +) -> list[Any]: + """拒绝在启动组合根装配前隐式读取服务配置。""" + raise RuntimeError("服务配置目录尚未由启动组合根配置") + + +def _unconfigured_modules(_module_type: ModuleType) -> list[Any]: + """拒绝在启动组合根装配前隐式抓取模块管理器。""" + raise RuntimeError("运行模块目录尚未由启动组合根配置") + + +_config_loader: ServiceConfigLoader = _unconfigured_configs +_module_loader: RunningModuleLoader = _unconfigured_modules + + +def configure_service_directory( + *, + configs: ServiceConfigLoader, + modules: RunningModuleLoader, +) -> None: + """由启动组合根注入服务配置和运行模块枚举端口。""" + global _config_loader, _module_loader + _config_loader = configs + _module_loader = modules + + +class ServiceBaseHelper(Generic[TConf]): + """通过应用端口查询服务配置和对应运行实例。""" + + def __init__( + self, + config_key: SystemConfigKey, + conf_type: Type[TConf], + module_type: ModuleType, + ) -> None: + """绑定配置类型和模块能力类型,不抓取具体 Runtime 管理器。""" + self.config_key = config_key + self.conf_type = conf_type + self.module_type = module_type + + def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: + """返回按名称索引的有效服务配置。""" + configs = _config_loader(self.config_key, self.conf_type) + return { + config.name: config + for config in configs + if config.name + and config.type + and (config.enabled or include_disabled) + } + + def get_config(self, name: str) -> Optional[TConf]: + """按名称返回单个启用服务配置。""" + return self.get_configs().get(name) if name else None + + def iterate_module_instances(self) -> Iterator[ServiceInfo]: + """迭代当前类型所有运行模块实例及其配置投影。""" + configs = self.get_configs() + for module in _module_loader(self.module_type): + if not module: + continue + instances = module.get_instances() + if not isinstance(instances, dict): + continue + for name, instance in instances.items(): + if not instance: + continue + config = configs.get(name) + yield ServiceInfo( + name=name, + instance=instance, + module=module, + type=config.type if config else None, + config=config, + ) + + def get_services( + self, + type_filter: Optional[str] = None, + name_filters: Optional[List[str]] = None, + ) -> Dict[str, ServiceInfo]: + """按服务类型和名称集合过滤运行实例。""" + names = set(name_filters) if name_filters else None + return { + service.name: service + for service in self.iterate_module_instances() + if service.config + and (type_filter is None or service.type == type_filter) + and (names is None or service.name in names) + } + + def get_service( + self, + name: str, + type_filter: Optional[str] = None, + ) -> Optional[ServiceInfo]: + """按名称和可选类型返回单个运行服务。""" + if not name: + return None + for service in self.iterate_module_instances(): + if ( + service.name == name + and service.config + and (type_filter is None or service.type == type_filter) + ): + return service + return None diff --git a/app/application/site/health.py b/app/application/site/health.py new file mode 100644 index 000000000..b05316686 --- /dev/null +++ b/app/application/site/health.py @@ -0,0 +1,65 @@ +"""站点访问统计写入应用服务。""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol + + +class SiteHealthRepository(Protocol): + """站点健康统计所需的最小写端口。""" + + def success(self, domain: str, seconds: Optional[int] = None) -> Any: + """记录站点访问成功。""" + ... + + def fail(self, domain: str) -> Any: + """记录站点访问失败。""" + ... + + async def async_success(self, domain: str, seconds: Optional[int] = None) -> Any: + """异步记录站点访问成功。""" + ... + + async def async_fail(self, domain: str) -> Any: + """异步记录站点访问失败。""" + ... + + +class SiteHealthService: + """集中承接索引模块的站点健康统计写操作。""" + + def __init__(self, repository: SiteHealthRepository) -> None: + """保存站点统计写端口。""" + self._repository = repository + + def success(self, domain: str, seconds: Optional[int] = None) -> Any: + """记录同步站点访问成功。""" + return self._repository.success(domain, seconds) + + def fail(self, domain: str) -> Any: + """记录同步站点访问失败。""" + return self._repository.fail(domain) + + async def async_success(self, domain: str, seconds: Optional[int] = None) -> Any: + """记录异步站点访问成功。""" + return await self._repository.async_success(domain, seconds) + + async def async_fail(self, domain: str) -> Any: + """记录异步站点访问失败。""" + return await self._repository.async_fail(domain) + + +_configured_site_health_service: SiteHealthService | None = None + + +def configure_site_health_service(service: SiteHealthService) -> None: + """由启动组合根登记站点健康统计服务。""" + global _configured_site_health_service + _configured_site_health_service = service + + +def get_configured_site_health_service() -> SiteHealthService: + """返回启动阶段登记的站点健康统计服务。""" + if _configured_site_health_service is None: + raise RuntimeError("站点健康统计服务尚未配置") + return _configured_site_health_service diff --git a/app/application/site/mutation.py b/app/application/site/mutation.py index 3a527c454..ef998aee4 100644 --- a/app/application/site/mutation.py +++ b/app/application/site/mutation.py @@ -41,6 +41,10 @@ class SiteMutationRepository(Protocol): """暂存一组站点优先级变更。""" ... + async def stage_reset(self) -> None: + """暂存清空全部站点。""" + ... + SiteIndexerLoader = Callable[[str], Awaitable[Optional[dict]]] SiteEventPublisher = Callable[[dict], Awaitable[None]] @@ -133,6 +137,13 @@ class SiteMutationCommand: await self._publish_deleted({"site_id": site_id}) return SiteMutationResult(True) + async def reset(self) -> SiteMutationResult: + """清空全部站点,并在提交后发布通配站点删除事件。""" + await self._repository.stage_reset() + await self._commit() + await self._publish_deleted({"site_id": "*"}) + return SiteMutationResult(True) + async def _commit(self) -> None: """提交当前站点事务,失败时回滚并保留原始异常。""" try: diff --git a/app/application/site/query.py b/app/application/site/query.py new file mode 100644 index 000000000..09db872f7 --- /dev/null +++ b/app/application/site/query.py @@ -0,0 +1,171 @@ +"""站点及站点运行数据的只读应用服务。""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol + +from app.schemas.site import SiteIconData, SiteStatistic, SiteUserData +from app.schemas.workflow import Site + + +class SiteQueryRepository(Protocol): + """站点查询用例需要的最小持久化端口。""" + + async def async_list_order_by_pri(self) -> list[Any]: + """按优先级读取站点。""" + ... + + async def async_list(self) -> list[Any]: + """读取全部站点。""" + ... + + async def async_get(self, site_id: int) -> Optional[Any]: + """按 ID 读取站点。""" + ... + + async def async_get_by_domain(self, domain: str) -> Optional[Any]: + """按域名读取站点。""" + ... + + async def async_get_userdata_latest(self) -> list[Any]: + """读取各站点最新用户数据。""" + ... + + async def async_get_userdata_by_domain( + self, + domain: str, + workdate: Optional[str] = None, + ) -> list[Any]: + """读取站点用户数据。""" + ... + + async def async_get_icon_by_domain(self, domain: str) -> Optional[Any]: + """按域名读取站点图标。""" + ... + + async def async_get_statistic_by_domain(self, domain: str) -> Optional[Any]: + """按域名读取站点统计。""" + ... + + async def async_list_statistics(self) -> list[Any]: + """读取全部站点统计。""" + ... + + def get(self, site_id: int) -> Optional[Any]: + """同步按 ID 读取站点。""" + ... + + def list(self) -> list[Any]: + """同步读取全部站点。""" + ... + + def list_order_by_pri(self) -> list[Any]: + """同步按优先级读取站点。""" + ... + + def get_userdata_latest(self) -> list[Any]: + """同步读取各站点最新用户数据。""" + ... + + +class SiteQueryService: + """把站点 ORM 投影为 API/Chain 可复用的稳定 DTO。""" + + def __init__(self, repository: SiteQueryRepository) -> None: + """保存站点查询仓储端口。""" + self._repository = repository + + async def list_ordered(self) -> list[Site]: + """按站点优先级返回配置 DTO。""" + return [Site.model_validate(item) for item in await self._repository.async_list_order_by_pri()] + + async def list(self) -> list[Site]: + """返回全部站点配置 DTO。""" + return [Site.model_validate(item) for item in await self._repository.async_list()] + + async def get(self, site_id: int) -> Optional[Site]: + """按 ID 返回站点配置 DTO。""" + item = await self._repository.async_get(site_id) + return Site.model_validate(item) if item else None + + def get_sync(self, site_id: int) -> Optional[Site]: + """同步按 ID 返回站点配置 DTO。""" + item = self._repository.get(site_id) + return Site.model_validate(item) if item else None + + def list_sync(self) -> list[Site]: + """同步返回全部站点配置 DTO。""" + return [ + Site.model_validate(item) + for item in self._repository.list_order_by_pri() + ] + + async def get_by_domain(self, domain: str) -> Optional[Site]: + """按域名返回站点配置 DTO。""" + item = await self._repository.async_get_by_domain(domain) + return Site.model_validate(item) if item else None + + async def userdata_latest(self) -> list[SiteUserData]: + """返回各站点最新用户数据 DTO。""" + return [ + SiteUserData.model_validate(item) + for item in await self._repository.async_get_userdata_latest() + ] + + async def userdata( + self, + domain: str, + workdate: Optional[str] = None, + ) -> list[SiteUserData]: + """返回指定站点用户数据 DTO。""" + return [ + SiteUserData.model_validate(item) + for item in await self._repository.async_get_userdata_by_domain( + domain, + workdate, + ) + ] + + async def icon(self, domain: str) -> Optional[SiteIconData]: + """返回站点图标 DTO。""" + item = await self._repository.async_get_icon_by_domain(domain) + if not item: + return None + return SiteIconData( + icon=item.base64 if item.base64 else item.url, + ) + + async def statistic(self, domain: str) -> SiteStatistic: + """返回指定站点统计 DTO,未命中时返回空统计。""" + 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) -> list[SiteStatistic]: + """返回全部站点统计 DTO。""" + return [ + SiteStatistic.model_validate(item) + for item in await self._repository.async_list_statistics() + ] + + def userdata_latest_sync(self) -> list[SiteUserData]: + """同步返回各站点最新用户数据 DTO。""" + return [ + SiteUserData.model_validate(item) + for item in self._repository.get_userdata_latest() + ] + + +_configured_site_query_service: SiteQueryService | None = None + + +def configure_site_query_service(service: SiteQueryService) -> None: + """由启动组合根登记站点查询服务。""" + global _configured_site_query_service + _configured_site_query_service = service + + +def get_configured_site_query_service() -> SiteQueryService: + """返回启动阶段登记的站点查询服务。""" + if _configured_site_query_service is None: + raise RuntimeError("站点查询服务尚未配置") + return _configured_site_query_service diff --git a/app/application/storage.py b/app/application/storage.py index dd7a5979a..d6bb8c8ec 100644 --- a/app/application/storage.py +++ b/app/application/storage.py @@ -1,7 +1,7 @@ from typing import List, Optional from app.schemas.system import StorageConf as _SchemaStorageConf -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.schemas.types import SystemConfigKey @@ -15,7 +15,7 @@ class StorageHelper: """ 获取所有存储设置 """ - storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages) + storage_confs: List[dict] = get_configured_system_config().get(SystemConfigKey.Storages) if not storage_confs: return [] return [_SchemaStorageConf(**s) for s in storage_confs] @@ -47,7 +47,7 @@ class StorageHelper: if s.type == storage: s.config = conf break - SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) + get_configured_system_config().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) def add_storage(self, storage: str, name: str, conf: dict): """ @@ -68,7 +68,7 @@ class StorageHelper: name=name, config=conf )) - SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) + get_configured_system_config().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) def reset_storage(self, storage: str): """ @@ -79,4 +79,4 @@ class StorageHelper: if s.type == storage: s.config = {} break - SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) + get_configured_system_config().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies]) diff --git a/app/application/subscribe.py b/app/application/subscribe.py index 3c1a82e62..960b03155 100644 --- a/app/application/subscribe.py +++ b/app/application/subscribe.py @@ -14,9 +14,9 @@ app/application/history.py 里整理历史的写入路径同构。 张表。同步与异步是两份逐字复制的实现,改一条漏一条就是真实缺陷,故翻译与身份构造由 下方 _translate 单点承担,两条链路只在「怎么查、怎么写」上分叉。 """ -from typing import Optional, Tuple +from collections.abc import Callable +from typing import Optional, Protocol, Tuple -from app.db.oper.subscribe import SubscribeOper from app.domain.context import MediaInfo, MusicInfo from app.schemas.media import resolve_media_identity from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType @@ -26,6 +26,39 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType INCOMPLETE_IDENTITY = (0, "媒体身份不完整") +class SubscribeWriter(Protocol): + """订阅写入应用服务使用的数据端口。""" + + def add(self, identity: dict, payload: dict, username: Optional[str] = None) -> Tuple[int, str]: + """同步新增订阅。""" + + async def async_add( + self, + identity: dict, + payload: dict, + username: Optional[str] = None, + ) -> Tuple[int, str]: + """异步新增订阅。""" + + +_configured_subscribe_writer: Callable[[], SubscribeWriter] | None = None + + +def configure_subscribe_writer(provider: Callable[[], SubscribeWriter]) -> None: + """由启动组合根登记订阅写入端口提供器。""" + global _configured_subscribe_writer + _configured_subscribe_writer = provider + + +def _get_subscribe_writer(writer: Optional[SubscribeWriter]) -> SubscribeWriter: + """获取显式传入或启动组合根登记的订阅写入端口。""" + if writer is not None: + return writer + if _configured_subscribe_writer is None: + raise RuntimeError("订阅写入端口尚未配置") + return _configured_subscribe_writer() + + def _music_entity(mediainfo: MediaInfo | MusicInfo) -> Optional[str]: """ 取音乐实体类型;非音乐媒体一律为空。 @@ -87,7 +120,7 @@ def _translate(mediainfo: MediaInfo | MusicInfo, def add_subscribe(mediainfo: MediaInfo | MusicInfo, - subscribe_oper: Optional[SubscribeOper] = None, + subscribe_oper: Optional[SubscribeWriter] = None, **kwargs) -> Tuple[int, str]: """ 新增订阅。 @@ -100,12 +133,12 @@ def add_subscribe(mediainfo: MediaInfo | MusicInfo, if translated is None: return INCOMPLETE_IDENTITY identity, payload, username = translated - oper = subscribe_oper or SubscribeOper() + oper = _get_subscribe_writer(subscribe_oper) return oper.add(identity=identity, payload=payload, username=username) async def async_add_subscribe(mediainfo: MediaInfo | MusicInfo, - subscribe_oper: Optional[SubscribeOper] = None, + subscribe_oper: Optional[SubscribeWriter] = None, **kwargs) -> Tuple[int, str]: """ 异步新增订阅。 @@ -118,5 +151,5 @@ async def async_add_subscribe(mediainfo: MediaInfo | MusicInfo, if translated is None: return INCOMPLETE_IDENTITY identity, payload, username = translated - oper = subscribe_oper or SubscribeOper() + oper = _get_subscribe_writer(subscribe_oper) return await oper.async_add(identity=identity, payload=payload, username=username) diff --git a/app/application/subscription/mutation.py b/app/application/subscription/mutation.py new file mode 100644 index 000000000..6f6f44357 --- /dev/null +++ b/app/application/subscription/mutation.py @@ -0,0 +1,149 @@ +"""订阅写操作用例及其数据端口。""" + +from dataclasses import dataclass +from typing import Any, Protocol + + +class SubscriptionMutationRepository(Protocol): + """订阅写用例需要的异步数据端口。""" + + async def async_get(self, subscribe_id: int) -> Any | None: + """按 ID 获取订阅。""" + + async def async_update(self, subscribe_id: int, payload: dict[str, Any]) -> Any | None: + """更新订阅。""" + + def get(self, subscribe_id: int) -> Any | None: + """同步按 ID 获取订阅。""" + + +class SubscriptionHistoryMutationRepository(Protocol): + """订阅历史删除用例需要的最小数据端口。""" + + async def async_get(self, history_id: int) -> Any | None: + """按 ID 获取订阅历史。""" + + async def async_delete(self, history_id: int) -> None: + """删除订阅历史。""" + + +@dataclass(frozen=True) +class SubscriptionActor: + """订阅写操作的权限主体。""" + + name: str + is_superuser: bool + + +@dataclass(frozen=True) +class SubscriptionMutation: + """一次订阅变更前后的稳定快照。""" + + old: dict[str, Any] + new: dict[str, Any] + + +class SubscriptionMutationService: + """编排订阅访问控制、更新和历史删除。""" + + def __init__( + self, + repository: SubscriptionMutationRepository, + history_repository: SubscriptionHistoryMutationRepository | None = None, + ) -> None: + """注入订阅和订阅历史数据端口。""" + self._repository = repository + self._history_repository = history_repository + + async def get_accessible( + self, + subscribe_id: int, + actor: SubscriptionActor, + ) -> Any | None: + """读取当前主体可访问的订阅。""" + subscribe = await self._repository.async_get(subscribe_id) + return subscribe if self.can_access(subscribe, actor) else None + + def get_accessible_sync( + self, + subscribe_id: int, + actor: SubscriptionActor, + ) -> Any | None: + """同步读取当前主体可访问的订阅。""" + subscribe = self._repository.get(subscribe_id) + return subscribe if self.can_access(subscribe, actor) else None + + async def update( + self, + subscribe_id: int, + payload: dict[str, Any], + actor: SubscriptionActor, + existing: Any | None = None, + ) -> SubscriptionMutation | None: + """更新当前主体可访问的订阅并返回前后快照。""" + subscribe = existing or await self.get_accessible(subscribe_id, actor) + if subscribe and not self.can_access(subscribe, actor): + return None + if not subscribe: + return None + old = subscribe.to_dict() + updated = await self._repository.async_update(subscribe_id, payload) + return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {}) + + async def update_status( + self, + subscribe_id: int, + state: str, + actor: SubscriptionActor, + ) -> SubscriptionMutation | None: + """更新订阅状态并返回前后快照。""" + return await self.update(subscribe_id, {"state": state}, actor) + + async def reset( + self, + subscribe_id: int, + actor: SubscriptionActor, + ) -> SubscriptionMutation | None: + """重置订阅进度和手工集数标记。""" + subscribe = await self.get_accessible(subscribe_id, actor) + if not subscribe: + return None + payload = { + "note": [], + "lack_episode": subscribe.total_episode, + "current_priority": None, + "current_audio_format": None, + "current_bitrate": None, + "current_bit_depth": None, + "current_sample_rate": None, + "episode_priority": {}, + "manual_total_episode": 0, + "state": "R", + } + old = subscribe.to_dict() + updated = await self._repository.async_update(subscribe_id, payload) + return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {}) + + async def delete_history( + self, + history_id: int, + actor: SubscriptionActor, + ) -> bool: + """删除当前主体可访问的订阅历史。""" + if self._history_repository is None: + raise RuntimeError("订阅历史数据端口未配置") + history = await self._history_repository.async_get(history_id) + if not self.can_access(history, actor): + return False + await self._history_repository.async_delete(history_id) + return True + + @staticmethod + def can_access(subscribe: Any, actor: SubscriptionActor) -> bool: + """判断主体是否可访问订阅或订阅历史。""" + if not subscribe: + return False + if actor.is_superuser: + return True + username = getattr(subscribe, "username", None) + return bool(username) and username == actor.name diff --git a/app/application/subscription/query.py b/app/application/subscription/query.py index bcd03dbeb..a6ef73950 100644 --- a/app/application/subscription/query.py +++ b/app/application/subscription/query.py @@ -8,6 +8,7 @@ from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase from app.schemas.media import resolve_media_identity from app.schemas.types import MediaType +from app.schemas.workflow import Subscribe as SubscribeView class SubscriptionQueryRepository(Protocol): @@ -26,6 +27,54 @@ class SubscriptionQueryRepository(Protocol): ... +class AsyncSubscriptionQueryRepository(Protocol): + """公开订阅查询所需的异步持久化端口。""" + + async def async_list(self) -> list[Any]: + """读取全部订阅。""" + ... + + async def async_list_by_username(self, username: str) -> list[Any]: + """读取指定用户订阅。""" + ... + + async def async_get(self, subscribe_id: int) -> Optional[Any]: + """按 ID 读取订阅。""" + ... + + async def async_list_by_media_identity( + self, + media_source: Any, + media_id: str, + music_type: Optional[str] = None, + ) -> list[Any]: + """按规范媒体身份读取订阅。""" + ... + + +class AsyncSubscriptionHistoryQueryRepository(Protocol): + """订阅历史公开查询所需的异步持久化端口。""" + + async def async_list_by_type( + self, + mtype: str, + page: int = 1, + count: int = 30, + ) -> list[Any]: + """按媒体类型分页读取订阅历史。""" + ... + + async def async_list_by_type_and_username( + self, + mtype: str, + username: str, + page: int = 1, + count: int = 30, + ) -> list[Any]: + """按媒体类型和用户分页读取订阅历史。""" + ... + + class SubscriptionQueryService: """封装不修改订阅状态的三个公开查询用例。""" @@ -37,9 +86,102 @@ class SubscriptionQueryService: "music_type", } - def __init__(self, repository: SubscriptionQueryRepository) -> None: + def __init__( + self, + repository: SubscriptionQueryRepository, + *, + async_repository: Optional[AsyncSubscriptionQueryRepository] = None, + history_repository: Optional[AsyncSubscriptionHistoryQueryRepository] = None, + ) -> None: """保存订阅查询仓储端口。""" self._repository = repository + self._async_repository = async_repository + self._history_repository = history_repository + + async def list_public( + self, + username: Optional[str] = None, + ) -> list[SubscribeView]: + """读取公开订阅列表并转换为稳定 DTO。""" + if self._async_repository is None: + raise RuntimeError("异步订阅查询端口未注册") + if username: + records = await self._async_repository.async_list_by_username( + username=username + ) + else: + records = await self._async_repository.async_list() + return [SubscribeView.model_validate(record) for record in records] + + async def get_public(self, subscribe_id: int) -> Optional[SubscribeView]: + """按 ID 读取订阅 DTO。""" + if self._async_repository is None: + raise RuntimeError("异步订阅查询端口未注册") + record = await self._async_repository.async_get(subscribe_id) + return SubscribeView.model_validate(record) if record else None + + async def list_by_media_identity( + self, + media_source: Any, + media_id: str, + music_type: Optional[str] = None, + ) -> list[SubscribeView]: + """按媒体身份读取订阅 DTO,并兼容旧音乐记录。""" + if self._async_repository is None: + raise RuntimeError("异步订阅查询端口未注册") + records = await self._async_repository.async_list_by_media_identity( + media_source=media_source, + media_id=media_id, + music_type=music_type, + ) + return [ + SubscribeView.model_validate(record) + for record in records + if self._matches_music_type(record, music_type) + ] + + async def list_history( + self, + mtype: str, + *, + page: int = 1, + count: int = 30, + username: Optional[str] = None, + ) -> list[SubscribeView]: + """分页读取订阅历史 DTO。""" + if self._history_repository is None: + raise RuntimeError("订阅历史查询端口未注册") + if username: + records = await self._history_repository.async_list_by_type_and_username( + mtype, + username, + page, + count, + ) + else: + records = await self._history_repository.async_list_by_type( + mtype, + page, + count, + ) + result = [] + for record in records: + item = SubscribeView.model_validate(record) + if item.type == MediaType.TV.value: + item.total_episode = 0 + item.lack_episode = 0 + result.append(item) + return result + + @staticmethod + def _matches_music_type(record: Any, music_type: Optional[str]) -> bool: + """把迁移前未标注音乐类型的记录兼容为单曲。""" + if not music_type: + return True + value = getattr(record, "music_type", None) + return value == music_type or ( + music_type == "recording" and value is None + ) def exists( self, diff --git a/app/application/torrent.py b/app/application/torrent.py index f7e7ddc55..6978f3801 100644 --- a/app/application/torrent.py +++ b/app/application/torrent.py @@ -13,8 +13,8 @@ from app.domain.context import Context, TorrentInfo, MediaInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import audio_quality_tier, normalize_audio_format, parse_audio_quality from app.domain.metainfo import MetaInfo -from app.db.oper.site import SiteOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.site.query import get_configured_site_query_service +from app.application.configuration import get_configured_system_config from app.runtime.log import logger from app.schemas.types import MediaType, SystemConfigKey from app.adapters.network.http import RequestUtils @@ -26,6 +26,27 @@ from app.foundation.crypto import HashUtils _SIZE_UNIT = 1024 * 1024 +# 站点首页、RSS 与音乐独立缓存的稳定键名;迁移脚本也复用这一事实来源。 +_TORRENT_CACHE_KEYS = ( + "__torrents_cache__", + "__rss_cache__", + "__torrents_music_cache__", + "__rss_music_cache__", +) + + +def clear_torrent_cache(cache_backend: Optional[Any] = None) -> None: + """清理站点首页、RSS 及音乐资源缓存,不依赖 Chain 运行上下文。 + + 数据库迁移可能发生在生命周期组件装配之前,不能为了清理缓存构造 + ``TorrentsChain`` 并隐式拉起插件、模块和消息依赖,因此这里直接使用缓存端口。 + + :param cache_backend: 可选缓存后端;未传入时使用当前宿主配置的文件缓存后端。 + """ + backend = cache_backend or FileCache() + for cache_key in _TORRENT_CACHE_KEYS: + backend.delete(cache_key) + @lru_cache(maxsize=512) def _compile_filter_pattern(pattern: str) -> re.Pattern: @@ -291,11 +312,12 @@ class TorrentHelper: return [] # 下载规则 - priority_rule: List[str] = SystemConfigOper().get( + priority_rule: List[str] = get_configured_system_config().get( SystemConfigKey.TorrentsPriority) or ["torrent", "upload", "seeder"] # 站点上传量 site_uploads = { - site.name: site.upload for site in SiteOper().get_userdata_latest() + site.name: site.upload + for site in get_configured_site_query_service().userdata_latest_sync() } def get_sort_str(_context): diff --git a/app/application/workflow.py b/app/application/workflow.py index 5b8d92bb5..b7879b850 100644 --- a/app/application/workflow.py +++ b/app/application/workflow.py @@ -17,6 +17,50 @@ SUPPORTED_WORKFLOW_TRIGGERS = { } +class AsyncWorkflowQueryRepository(Protocol): + """工作流查询用例需要的异步读取端口。""" + + async def async_list(self) -> list[Any]: + """读取全部工作流。""" + ... + + async def async_get(self, workflow_id: int) -> Optional[Any]: + """按 ID 读取工作流。""" + ... + + +class WorkflowQueryService: + """提供工作流列表和详情查询,隔离 API 与数据库会话。""" + + def __init__(self, repository: AsyncWorkflowQueryRepository) -> None: + """保存请求级异步查询端口。""" + self._repository = repository + + async def list(self) -> list[Any]: + """返回全部工作流。""" + return await self._repository.async_list() + + async def get(self, workflow_id: int) -> Optional[Any]: + """返回指定工作流。""" + return await self._repository.async_get(workflow_id) + + +_configured_workflow_query: WorkflowQueryService | None = None + + +def configure_workflow_query(service: WorkflowQueryService) -> None: + """由启动组合根登记工作流查询服务。""" + global _configured_workflow_query + _configured_workflow_query = service + + +def get_configured_workflow_query() -> WorkflowQueryService: + """返回启动阶段登记的工作流查询服务。""" + if _configured_workflow_query is None: + raise RuntimeError("工作流查询服务尚未配置") + return _configured_workflow_query + + @dataclass(frozen=True, slots=True) class WorkflowMutationResult: """描述工作流写操作是否成功及兼容提示信息。""" diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 73c46b5d0..b4390fd72 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -8,11 +8,11 @@ from pathlib import Path from typing import Optional, Any, Tuple, List, Set, Union, Dict from app.application.chain.context import ChainRuntimeContext, get_chain_runtime_context +from app.application.chain.data import get_chain_data_ports from app.chain._messaging import MessageProcessingMixin, NotificationMixin from app.chain._recognition import RecognitionMixin from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo from app.domain.meta.metabase import MetaBase -from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.runtime.log import logger from app.schemas.exception import RateLimitExceededException from app.schemas.transfer import TransferInfo @@ -52,7 +52,8 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, self.pluginmanager = context.plugin_manager self.filecache = context.file_cache self.async_filecache = context.async_file_cache - self._module_dispatcher = ModuleInvocationDispatcher( + self.data_ports = context.data_ports or get_chain_data_ports() + self._module_dispatcher = context.module_dispatcher_factory( module_catalog=self.modulemanager, plugin_catalog=self.pluginmanager, plugin_error_handler=self.__handle_plugin_error, diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py index cc5bccdfd..2c2e15cdb 100644 --- a/app/chain/_messaging.py +++ b/app/chain/_messaging.py @@ -8,13 +8,13 @@ import copy from datetime import datetime from typing import Any, Dict, List, Optional, Union -from app.db.oper.user import UserOper +from app.application.chain.data import UserPortProxy as UserOper from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.foundation.identity import normalize_internal_user_id from app.application.messaging.message import MessageTemplateHelper from app.runtime.config import settings -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger from app.schemas.message import MessageResponse from app.schemas.message import Message diff --git a/app/chain/_music.py b/app/chain/_music.py index c8a489aec..ff95f6f0e 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -9,9 +9,8 @@ from app.application.subscription.contract import ( from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.search import SearchChain -from app.db.models.subscribe import Subscribe -from app.db.oper.subscribe import SubscribeOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.chain.data import SubscribePortProxy as SubscribeOper +from app.application.configuration import get_configured_system_config from app.domain.context import Context, MediaInfo, MusicInfo from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES from app.domain.meta.metamusic import MetaMusic @@ -23,6 +22,8 @@ from app.schemas.types import ( SystemConfigKey, ) +Subscribe = Any + def _normalize_music_total_tracks(value: Any) -> Optional[int]: """将专辑曲目总数归一为正整数,无效或未知值返回 None。""" @@ -255,7 +256,7 @@ class MusicSubscribeMixin: sites = self.get_sub_sites(subscribe) default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups - rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] + rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or [] torrent_helper = TorrentHelper() matched: List[Context] = [] for source_context in contexts or []: @@ -369,7 +370,7 @@ class MusicSubscribeMixin: sites = self.get_sub_sites(subscribe) default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups - rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] + rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or [] keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo) if not keywords: keywords = [subscribe.name] diff --git a/app/chain/_recognition.py b/app/chain/_recognition.py index 656e9ef71..031ac1d12 100644 --- a/app/chain/_recognition.py +++ b/app/chain/_recognition.py @@ -10,7 +10,7 @@ from typing import Optional from fastapi.concurrency import run_in_threadpool from app.adapters.external.server import MoviePilotServerHelper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic @@ -85,7 +85,7 @@ class RecognitionMixin: def _record_media_recognize_share_hit() -> None: """记录一次共享媒体识别成功命中,统计失败不影响识别结果。""" try: - SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount) + get_configured_system_config().increment(SystemConfigKey.MediaRecognizeShareCount) except Exception as err: logger.error(f"记录共享媒体识别命中次数失败:{str(err)}") diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index 5f2bbb824..653c4e7c3 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -22,11 +22,11 @@ from app.application.transfer import TransferTask, job_lock from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.chain.subscribe import SubscribeChain -from app.db.models.downloadhistory import DownloadFiles, DownloadHistory -from app.db.models.transferhistory import TransferHistory -from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.transferhistory import TransferHistoryOper +from app.application.chain.data import ( + DownloadHistoryPortProxy as DownloadHistoryOper, + TransferHistoryPortProxy as TransferHistoryOper, +) +from app.application.configuration import get_configured_system_config from app.domain.context import MediaInfo, MusicInfo from app.domain.media import normalize_music_type from app.domain.meta.metabase import MetaBase @@ -48,6 +48,10 @@ from app.schemas.types import ( SystemConfigKey, ) +DownloadFiles = Any +DownloadHistory = Any +TransferHistory = Any + # 字幕文件常见的语言/默认/强制标记,整理同名字幕时只允许剥离这些字幕专属尾缀。 SUBTITLE_STEM_TAGS = { "cc", @@ -725,7 +729,7 @@ class EpisodeFormatMixin: """ 获取启用的集数定位规则 """ - rule_items = SystemConfigOper().get(SystemConfigKey.EpisodeFormatRuleTable) or [] + rule_items = get_configured_system_config().get(SystemConfigKey.EpisodeFormatRuleTable) or [] rules: List[_SchemaEpisodeFormatRule] = [] for item in rule_items: if not isinstance(item, dict): diff --git a/app/chain/download.py b/app/chain/download.py index 67aef813f..ede6b9381 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -28,9 +28,11 @@ from app.runtime.events import eventmanager, Event from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo -from app.db.oper.downloadfailure import DownloadFailureOper -from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.oper.mediaserver import MediaServerOper +from app.application.chain.data import ( + DownloadFailurePortProxy as DownloadFailureOper, + DownloadHistoryPortProxy as DownloadHistoryOper, + MediaServerPortProxy as MediaServerOper, +) from app.application.directory import DirectoryHelper, validate_download_save_path from app.application.download.tasks import DownloadTaskService from app.runtime.thread import ThreadHelper @@ -53,7 +55,9 @@ from app.foundation import text as text_tools from app.adapters.system.host import SystemUtils if TYPE_CHECKING: - from app.db.models.downloadfailure import DownloadFailure + from typing import Any + + DownloadFailure = Any DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS = 24 * 60 * 60 diff --git a/app/chain/interaction.py b/app/chain/interaction.py index 067ebdbdf..d947f2241 100644 --- a/app/chain/interaction.py +++ b/app/chain/interaction.py @@ -13,7 +13,7 @@ from app.application.messaging.media import ( media_interaction_manager, ) from app.application.torrent import TorrentHelper -from app.db.oper.user import UserOper +from app.application.chain.data import UserPortProxy as UserOper from app.domain import episode as episode_rules from app.domain import title as title_rules from app.domain.context import Context, MediaInfo diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index 3655fa14c..3413a71e8 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -4,8 +4,8 @@ from typing import Callable, Dict, List, Union, Optional, Generator, Any from app.chain import ChainBase from app.runtime.config import global_vars -from app.db.oper.mediaserver import MediaServerOper -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.application.chain.data import MediaServerPortProxy as MediaServerOper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.log import logger from app.schemas.mediaserver import MediaServerLibrary from app.schemas.mediaserver import MediaServerItem diff --git a/app/chain/scraping.py b/app/chain/scraping.py index ce919c0b1..2da1ebe54 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -23,7 +23,7 @@ from app.runtime.events import eventmanager, Event from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.application.audio import AudioMetadataHelper from app.runtime.log import logger from app.schemas.workflow import FileItem @@ -146,7 +146,7 @@ class ScrapingConfig: :return: MediaScrapingConfig 实例 """ - user_config = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {} + user_config = get_configured_system_config().get(SystemConfigKey.ScrapingSwitchs) or {} return cls(user_config) @staticmethod diff --git a/app/chain/search.py b/app/chain/search.py index 4b3e2816c..019b903a5 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -21,7 +21,7 @@ from app.runtime.events import eventmanager, Event from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.domain.context import MusicInfo -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config from app.runtime.progress import ProgressHelper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.application.search.state import ( @@ -1039,7 +1039,7 @@ class SearchChain(ChainBase): # 记录过滤前的候选资源数,供前端在全部被过滤时给出友好提示 candidate_count = 0 if rule_groups is None: - rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] + rule_groups = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or [] async for event in self.__async_search_all_sites_stream( keyword=title, sites=sites, page=page, mtype=mtype): result = event.pop("items", []) or [] @@ -1104,7 +1104,7 @@ class SearchChain(ChainBase): return [] if rule_groups is None: - rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] + rule_groups = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or [] if not rule_groups: return torrents @@ -1311,7 +1311,7 @@ class SearchChain(ChainBase): # 开始过滤规则过滤 if rule_groups is None: # 取搜索过滤规则 - rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) + rule_groups: List[str] = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) if rule_groups: logger.info(f'开始过滤规则/剧集过滤,使用规则组:{rule_groups} ...') torrents = __do_parallel_filter(torrents) @@ -1439,7 +1439,7 @@ class SearchChain(ChainBase): if torrenthelper.filter_torrent(torrent, filter_params) ] if rule_groups is None: - rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] + rule_groups = get_configured_system_config().get(SystemConfigKey.SearchFilterRuleGroups) or [] if rule_groups and torrents: torrents = self.filter_torrents( rule_groups=rule_groups, @@ -2272,7 +2272,7 @@ class SearchChain(ChainBase): # 配置的索引站点 if not sites: - sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] + sites = get_configured_system_config().get(SystemConfigKey.IndexerSites) or [] for indexer in SitesHelper().get_indexers(): # 检查站点索引开关 @@ -2386,7 +2386,7 @@ class SearchChain(ChainBase): # 配置的索引站点 if not sites: - sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] + sites = get_configured_system_config().get(SystemConfigKey.IndexerSites) or [] for indexer in await SitesHelper().async_get_indexers(): # 检查站点索引开关 @@ -2509,7 +2509,7 @@ class SearchChain(ChainBase): indexer_sites = [] if not sites: - sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] + sites = get_configured_system_config().get(SystemConfigKey.IndexerSites) or [] for indexer in await SitesHelper().async_get_indexers(): if not sites or indexer.get("id") in sites: @@ -2646,7 +2646,7 @@ class SearchChain(ChainBase): indexer_sites = [] if not sites: - sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] + sites = get_configured_system_config().get(SystemConfigKey.IndexerSites) or [] for indexer in await SitesHelper().async_get_indexers(): if not indexer.get("subtitles"): @@ -2742,7 +2742,7 @@ class SearchChain(ChainBase): indexer_sites = [] if not sites: - sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] + sites = get_configured_system_config().get(SystemConfigKey.IndexerSites) or [] for indexer in await SitesHelper().async_get_indexers(): if not indexer.get("subtitles"): @@ -2871,10 +2871,10 @@ class SearchChain(ChainBase): return if site_id == "*": # 清空搜索站点 - SystemConfigOper().set(SystemConfigKey.IndexerSites, []) + get_configured_system_config().set(SystemConfigKey.IndexerSites, []) return # 从选中的rss站点中移除 - selected_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or [] + selected_sites = get_configured_system_config().get(SystemConfigKey.IndexerSites) or [] if site_id in selected_sites: selected_sites.remove(site_id) - SystemConfigOper().set(SystemConfigKey.IndexerSites, selected_sites) + get_configured_system_config().set(SystemConfigKey.IndexerSites, selected_sites) diff --git a/app/chain/site.py b/app/chain/site.py index 48b0554cf..4616477b4 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -1,7 +1,7 @@ import base64 import re from datetime import datetime -from typing import Callable, Optional, Tuple, Union, Dict +from typing import Any, Callable, Optional, Tuple, Union, Dict from urllib.parse import urljoin from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module @@ -11,9 +11,8 @@ from app.chain import ChainBase from app.chain._interaction import InteractionChainMixin from app.runtime.config import global_vars, settings from app.runtime.events import Event, eventmanager -from app.db.models.site import Site -from app.db.oper.site import SiteOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.chain.data import SitePortProxy as SiteOper +from app.application.configuration import get_configured_system_config from app.adapters.network.browser import PlaywrightHelper from app.adapters.network.cloudflare import under_challenge from app.application.security.cookie import CookieHelper @@ -32,6 +31,8 @@ from app.foundation import size as size_tools from app.foundation import url as url_tools from app.foundation.dom import DomUtils +Site = Any + class SiteChain(InteractionChainMixin, ChainBase): """ @@ -640,7 +641,7 @@ class SiteChain(InteractionChainMixin, ChainBase): # 获取主域名中间那段 domain_host = url_tools.host_label(domain) # 查询以"site.domain_host"开头的配置项,并清除 - systemconfig = SystemConfigOper() + systemconfig = get_configured_system_config() site_keys = systemconfig.all().keys() for key in site_keys: if key.startswith(f"site.{domain_host}"): @@ -751,7 +752,11 @@ class SiteChain(InteractionChainMixin, ChainBase): def _interaction_handler(self) -> "SiteInteractionHandler": """构造 /sites 交互处理器,Cookie 更新动作由本链提供。""" - return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie) + return SiteInteractionHandler( + messenger=self, + cookie_updater=self.update_cookie, + repository=SiteOper(), + ) def remote_disable(self, arg_str: str, channel: NotificationChannel, userid: Union[str, int] = None, source: Optional[str] = None): diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 0a2a8ebe6..60fa41535 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -33,11 +33,12 @@ from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.meta.words import WordsMatcher from app.domain.metainfo import MetaInfo -from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.models.subscribe import Subscribe -from app.db.oper.site import SiteOper -from app.db.oper.subscribe import SubscribeOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.chain.data import ( + DownloadHistoryPortProxy as DownloadHistoryOper, + SitePortProxy as SiteOper, + SubscribePortProxy as SubscribeOper, +) +from app.application.configuration import get_configured_system_config from app.application.messaging.subscribe import SubscribeInteractionHandler from app.application.mediaserver import MediaServerHelper from app.application.subscribe import add_subscribe, async_add_subscribe @@ -56,6 +57,43 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, System ContentType from app.schemas.media import normalize_media_source, resolve_media_identity +if hasattr(_SchemaSubscribe, "model_fields"): + Subscribe = _SchemaSubscribe +else: + class Subscribe(_SchemaSubscribe): + """隔离测试或旧插件缺少完整订阅模型时使用的轻量快照。""" + + def __init__(self, **kwargs): + """初始化兼容快照并补齐链路必需的运行字段。""" + super().__init__(**kwargs) + for field, default in { + "best_version_full": 0, + "current_priority": None, + "episode_priority": None, + "total_episode": 0, + "start_episode": 0, + "lack_episode": 0, + "note": [], + }.items(): + if not hasattr(self, field): + setattr(self, field, default) + + def to_dict(self) -> dict: + """返回快照字段,兼容整理链的响应转换。""" + return dict(self.__dict__) + +# 旧测试与第三方扩展可能替换该名字;它现在只是应用配置端口的本地别名, +# 不再指向数据库 Oper。 +SystemConfigOper = get_configured_system_config +_DEFAULT_SYSTEM_CONFIG_PROVIDER = get_configured_system_config + + +def _system_config(): + """返回配置端口,并兼容旧测试对本地别名的替换。""" + if SystemConfigOper is not _DEFAULT_SYSTEM_CONFIG_PROVIDER: + return SystemConfigOper() + return get_configured_system_config() + def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: """兼容旧导入路径,转发订阅媒体元数据构造。""" @@ -1345,10 +1383,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): # 优先级过滤规则 if subscribe.best_version: rule_groups = subscribe.filter_groups \ - or SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or [] + or _system_config().get(SystemConfigKey.BestVersionFilterRuleGroups) or [] else: rule_groups = subscribe.filter_groups \ - or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or [] + or _system_config().get(SystemConfigKey.SubscribeFilterRuleGroups) or [] # 搜索,同时电视剧会过滤掉不需要的剧集 contexts = SearchChain().process(mediainfo=mediainfo, @@ -1631,7 +1669,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): :return: 涉及的站点清单 """ # 从系统配置获取默认订阅站点 - default_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] + default_sites = _system_config().get(SystemConfigKey.RssSites) or [] # 如果订阅未指定站点,直接返回默认站点 if not subscribe.sites: return default_sites @@ -1832,7 +1870,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): # 遍历预识别后的种子 _match_context = [] torrenthelper = TorrentHelper() - systemconfig = SystemConfigOper() + systemconfig = _system_config() wordsmatcher = WordsMatcher() for domain, contexts in processed_torrents.items(): if global_vars.is_system_stopped: @@ -2231,7 +2269,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): :param progress_callback: 定时服务进度更新回调 """ - follow_users: List[str] = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) + follow_users: List[str] = _system_config().get(SystemConfigKey.FollowSubscribers) if not follow_users: if progress_callback: progress_callback(value=100, text="未配置 Follow 订阅用户,跳过刷新") @@ -2835,7 +2873,12 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): def _interaction_handler(self) -> "SubscribeInteractionHandler": """构造 /subscribes 交互处理器,业务动作由本链提供。""" - return SubscribeInteractionHandler(messenger=self, actions=self) + return SubscribeInteractionHandler( + messenger=self, + actions=self, + repository=SubscribeOper(), + report_deleted=MoviePilotServerHelper.sub_done_async, + ) def remote_delete(self, arg_str: str, channel: NotificationChannel, userid: Union[str, int] = None, source: Optional[str] = None): @@ -3012,7 +3055,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): subscribeoper = SubscribeOper() if site_id == "*": # 站点被重置 - SystemConfigOper().set(SystemConfigKey.RssSites, []) + _system_config().set(SystemConfigKey.RssSites, []) for subscribe in subscribeoper.list(): if not subscribe.sites: continue @@ -3021,10 +3064,10 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): }) return # 从选中的rss站点中移除 - selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] + selected_sites = _system_config().get(SystemConfigKey.RssSites) or [] if site_id in selected_sites: selected_sites.remove(site_id) - SystemConfigOper().set(SystemConfigKey.RssSites, selected_sites) + _system_config().set(SystemConfigKey.RssSites, selected_sites) # 查询所有订阅 for subscribe in subscribeoper.list(): if not subscribe.sites: @@ -3057,7 +3100,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if hasattr(settings, default_subscribe_key): value = getattr(settings, default_subscribe_key) else: - value = SystemConfigOper().get(default_subscribe_key) + value = _system_config().get(default_subscribe_key) if not value: return None @@ -3069,7 +3112,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): 获取订阅默认参数 """ # 默认过滤规则 - default_rule = SystemConfigOper().get(SystemConfigKey.SubscribeDefaultParams) or {} + default_rule = _system_config().get(SystemConfigKey.SubscribeDefaultParams) or {} return { key: value for key, value in { "include": subscribe.include or default_rule.get("include"), diff --git a/app/chain/system.py b/app/chain/system.py index 267daa4de..8d99e1da9 100644 --- a/app/chain/system.py +++ b/app/chain/system.py @@ -6,7 +6,7 @@ from typing import Union, Optional from app.chain import ChainBase from app.runtime.config import settings -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager from app.runtime.state import SystemHelper from app.runtime.log import logger from app.schemas.message import Message @@ -154,7 +154,7 @@ class SystemChain(ChainBase): logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目") # 安装缺少的依赖 - PluginManager.install_plugin_missing_dependencies() + get_plugin_manager().install_plugin_missing_dependencies() # 删除备份目录 try: diff --git a/app/chain/torrents.py b/app/chain/torrents.py index 41932b1ef..de5022a25 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -12,8 +12,8 @@ from app.domain.context import TorrentInfo, Context, MediaInfo from app.domain.context import MusicInfo from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo -from app.db.oper.site import SiteOper -from app.db.oper.systemconfig import SystemConfigOper +from app.application.chain.data import SitePortProxy as SiteOper +from app.application.configuration import get_configured_system_config from app.application.rss import RssHelper from app.application.torrent import TorrentHelper from app.runtime.log import logger @@ -556,7 +556,7 @@ class TorrentsChain(ChainBase): # 刷新站点 if not sites: - sites = SystemConfigOper().get(SystemConfigKey.RssSites) or [] + sites = get_configured_system_config().get(SystemConfigKey.RssSites) or [] # 读取缓存,影视与音乐分别独立存储 if stype == 'spider': diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 50684f0cc..fed5c25c2 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -18,11 +18,13 @@ from app.runtime.events import eventmanager from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfoPath -from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.models.downloadhistory import DownloadHistory -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.transferpending import TransferPendingOper -from app.db.oper.transferhistory import TransferHistoryOper +from app.application.chain.data import ( + DownloadHistoryPortProxy as DownloadHistoryOper, + TransferPendingPortProxy as TransferPendingOper, + TransferHistoryPortProxy as TransferHistoryOper, +) +DownloadHistory = Any +from app.application.configuration import get_configured_system_config from app.application.directory import DirectoryHelper from app.application.formatting import FormatParser from app.runtime.progress import ProgressHelper @@ -53,8 +55,14 @@ from app.schemas.types import ( MediaSource, ) from app.runtime.reload import ConfigReloadMixin -from app.application.transfer import (FailedRetryScheduler, JobManager, - TransferQueueService, TransferTask, job_lock) +from app.application.transfer import ( + FailedRetryScheduler, + JobManager, + TransferQueue, + TransferQueueService, + TransferTask, + job_lock, +) from app.chain._transfer import (EpisodeFormatMixin, FailedRetryMixin, FileFilterMixin, FileKeyMixin, HistoryMatchMixin, ManualHistoryMixin, @@ -437,7 +445,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo tasks = self.jobview.success_tasks( task.mediainfo, task.meta.begin_season ) - system_config_oper = SystemConfigOper() + system_config_oper = get_configured_system_config() # 获取整理屏蔽词 transfer_exclude_words = system_config_oper.get( SystemConfigKey.TransferExcludeWords @@ -1579,7 +1587,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo ) # 整理屏蔽词 - transfer_exclude_words = SystemConfigOper().get( + transfer_exclude_words = get_configured_system_config().get( SystemConfigKey.TransferExcludeWords ) # 汇总错误信息 diff --git a/app/chain/user.py b/app/chain/user.py index 4f4a2e3c3..d34f74a85 100644 --- a/app/chain/user.py +++ b/app/chain/user.py @@ -1,12 +1,11 @@ import secrets from dataclasses import dataclass -from typing import Literal, Optional, Tuple, Union +from typing import Any, Literal, Optional, Tuple, Union from app.chain import ChainBase from app.runtime.config import settings -from app.application.security.access import get_password_hash, verify_password -from app.db.models.user import User -from app.db.oper.user import UserOper +from app.application.security.token import get_password_hash, verify_password +from app.application.chain.data import UserPortProxy as UserOper from app.runtime.log import logger from app.schemas.event import AuthCredentials from app.schemas.event import AuthInterceptCredentials @@ -14,6 +13,7 @@ from app.schemas.types import ChainEventType from app.application.security.otp import OtpUtils PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名、密码或验证码错误" +User = Any MfaMethod = Literal["otp"] diff --git a/app/chain/workflow.py b/app/chain/workflow.py index 36fbf0888..a52a5b0d1 100644 --- a/app/chain/workflow.py +++ b/app/chain/workflow.py @@ -15,8 +15,7 @@ from pydantic import BaseModel from app.chain import ChainBase from app.runtime.config import global_vars from app.runtime.events import Event, eventmanager -from app.db.models import Workflow -from app.db.oper.workflow import WorkflowOper +from app.application.chain.data import WorkflowPortProxy as WorkflowOper from app.runtime.log import logger from app.schemas.workflow import ActionContext from app.schemas.workflow import ActionFlow @@ -29,6 +28,7 @@ from app.workflow import WorkFlowManager ARTIFACT_FIELDS = {"torrents", "medias", "fileitems", "downloads", "sites", "subscribes"} DEFAULT_WORKFLOW_MAX_WORKERS = 4 CIRCULAR_REFERENCE_PLACEHOLDER = "[Circular]" +Workflow = Any def _serialize_workflow_key(key: Any) -> Any: diff --git a/app/command.py b/app/command.py index e9f553e8b..03df6e2e5 100644 --- a/app/command.py +++ b/app/command.py @@ -11,7 +11,7 @@ from app.chain.subscribe import SubscribeChain from app.chain.system import SystemChain from app.chain.transfer import TransferChain from app.runtime.events import Event as ManagerEvent, eventmanager, Event -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager as PluginManager from app.application.messaging.message import MessageHelper from app.application.messaging.skill import SkillInteractionHandler from app.runtime.thread import ThreadHelper diff --git a/app/db/health.py b/app/db/health.py new file mode 100644 index 000000000..acbcdc9dd --- /dev/null +++ b/app/db/health.py @@ -0,0 +1,17 @@ +"""数据库技术边界的连通性探测实现。""" + +from sqlalchemy import text + +from app.db.session import SessionFactory + + +def probe_database() -> str | None: + """执行最小数据库查询,成功返回空值,失败返回错误文本。""" + session = SessionFactory() + try: + session.execute(text("SELECT 1")) + except Exception as err: # noqa: BLE001 探测需要把驱动错误转为诊断文本 + return str(err) + finally: + session.close() + return None diff --git a/app/db/oper/downloadhistory.py b/app/db/oper/downloadhistory.py index ff6042d3b..38ab26b70 100644 --- a/app/db/oper/downloadhistory.py +++ b/app/db/oper/downloadhistory.py @@ -140,6 +140,14 @@ class DownloadHistoryOper(DbOper): """ return DownloadHistory.list_by_page(self._db, page, count) + async def async_list_by_page( + self, + page: int = 1, + count: int = 30, + ) -> List[DownloadHistory]: + """异步分页查询下载历史。""" + return await DownloadHistory.async_list_by_page(self._db, page, count) + async def async_delete_history(self, historyid: int): """ 异步删除下载记录。 diff --git a/app/db/oper/passkey.py b/app/db/oper/passkey.py new file mode 100644 index 000000000..40aca00e9 --- /dev/null +++ b/app/db/oper/passkey.py @@ -0,0 +1,36 @@ +"""PassKey 数据访问适配器。""" + +from typing import Any, Optional + +from app.db.base import DbOper +from app.db.models.passkey import PassKey + + +class PassKeyOper(DbOper): + """封装 PassKey 查询和维护,避免 API 层直接引用模型静态方法。""" + + def list_by_user_id(self, user_id: int) -> list[PassKey]: + """读取用户启用的 PassKey。""" + return PassKey.get_by_user_id(self._db, user_id) + + def list(self) -> list[PassKey]: + """读取全部 PassKey,用于判断系统是否已配置通行密钥。""" + return PassKey.list(self._db) + + def get_by_credential_id(self, credential_id: str) -> Optional[PassKey]: + """按凭证 ID 读取启用的 PassKey。""" + return PassKey.get_by_credential_id(self._db, credential_id) + + def create(self, payload: dict[str, Any]) -> PassKey: + """创建 PassKey 凭证。""" + passkey = PassKey(**payload) + passkey.create(self._db) + return passkey + + def update_last_used(self, passkey: PassKey, sign_count: int) -> bool: + """更新凭证最后使用时间和签名计数。""" + return bool(passkey.update_last_used(self._db, sign_count)) + + def delete_by_id(self, passkey_id: int, user_id: int) -> bool: + """删除指定用户的凭证。""" + return bool(PassKey.delete_by_id(self._db, passkey_id, user_id)) diff --git a/app/db/oper/site.py b/app/db/oper/site.py index 3c3c39865..b367b90d5 100644 --- a/app/db/oper/site.py +++ b/app/db/oper/site.py @@ -91,6 +91,10 @@ class SiteOper(DbOper): """ return await Site.async_list(self._db) + async def async_list_order_by_pri(self) -> List[Site]: + """异步按优先级获取站点,供站点查询应用服务使用。""" + return await Site.async_list_order_by_pri(self._db) + def list_order_by_pri(self) -> List[Site]: """ 获取站点列表 @@ -115,6 +119,14 @@ class SiteOper(DbOper): """ Site.delete(self._db, sid) + def reset(self) -> None: + """清空站点表,保留站点模型细节在数据库适配层。""" + Site.reset(self._db) + + async def stage_reset(self) -> None: + """暂存清空站点表,由应用事务统一提交。""" + await self._db.execute(sqlalchemy_delete(Site)) + def update(self, sid: int, payload: dict) -> Optional[Site]: """ 更新站点 @@ -235,6 +247,25 @@ class SiteOper(DbOper): self._db, domain=domain, workdate=workdate ) + async def async_get_userdata_latest(self) -> List[SiteUserData]: + """异步获取各站点最新用户数据。""" + return await SiteUserData.async_get_latest(self._db) + + async def async_get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]: + """异步按域名获取站点图标。""" + return await SiteIcon.async_get_by_domain(self._db, domain) + + async def async_get_statistic_by_domain( + self, + domain: str, + ) -> Optional[SiteStatistic]: + """异步按域名获取站点统计。""" + return await SiteStatistic.async_get_by_domain(self._db, domain) + + async def async_list_statistics(self) -> List[SiteStatistic]: + """异步获取所有站点统计。""" + return await SiteStatistic.async_list(self._db) + 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 3a1e92373..3b9e6bbbf 100644 --- a/app/db/oper/subscribe.py +++ b/app/db/oper/subscribe.py @@ -145,6 +145,21 @@ class SubscribeOper(DbOper): } return bool(Subscribe.exists(self._db, **identity_params)) + async def async_exists( + self, media_source: MediaSource, media_id: str, + season: Optional[int] = None, episode_group: Optional[str] = None, + music_type: Optional[str] = None, + ) -> Optional[Subscribe]: + """异步按媒体身份、季号及可选剧集组读取命中的订阅。""" + return await Subscribe.async_exists( + self._db, + media_source=media_source, + media_id=media_id, + music_type=music_type, + season=season, + episode_group=episode_group, + ) + def get(self, sid: int) -> Optional[Subscribe]: """ 获取订阅 @@ -157,6 +172,34 @@ class SubscribeOper(DbOper): """ return await Subscribe.async_get(self._db, rid=sid) + async def async_list_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + music_type: Optional[str] = None, + ) -> List[Subscribe]: + """异步按规范媒体身份读取订阅。""" + return await Subscribe.async_list_by_media_identity( + self._db, + media_source=media_source, + media_id=media_id, + music_type=music_type, + ) + + def list_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + music_type: Optional[str] = None, + ) -> List[Subscribe]: + """同步按规范媒体身份读取订阅。""" + return Subscribe.list_by_media_identity( + self._db, + media_source=media_source, + media_id=media_id, + music_type=music_type, + ) + async def get_candidate( self, subscribe_id: int, @@ -266,6 +309,32 @@ class SubscribeOper(DbOper): return await Subscribe.async_get_by_state(self._db, state) return await Subscribe.async_list(self._db) + async def async_list_by_username( + self, + username: str, + state: Optional[str] = None, + mtype: Optional[str] = None, + ) -> List[Subscribe]: + """异步按用户获取订阅。""" + return await Subscribe.async_list_by_username( + self._db, + username=username, + state=state, + mtype=mtype, + ) + + async def async_list_by_title( + self, + title: str, + season: Optional[int] = None, + ) -> List[Subscribe]: + """异步按标题获取订阅,供旧查询测试和迁移调用兼容。""" + return await Subscribe.async_list_by_title( + self._db, + title=title, + season=season, + ) + def delete(self, sid: int): """ 删除订阅 diff --git a/app/db/oper/subscribehistory.py b/app/db/oper/subscribehistory.py index d33286e1d..63d6ea8cb 100644 --- a/app/db/oper/subscribehistory.py +++ b/app/db/oper/subscribehistory.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from app.db.base import DbOper from app.db.models.subscribehistory import SubscribeHistory @@ -24,3 +24,27 @@ class SubscribeHistoryOper(DbOper): page=page, count=count, ) + + async def async_list_by_type_and_username( + self, + mtype: str, + username: str, + page: int = 1, + count: int = 30, + ) -> List[SubscribeHistory]: + """异步按媒体类型和用户分页查询订阅历史。""" + return await SubscribeHistory.async_list_by_type_and_username( + self._db, + mtype=mtype, + username=username, + page=page, + count=count, + ) + + async def async_get(self, history_id: int) -> Optional[SubscribeHistory]: + """异步按 ID 查询订阅历史。""" + return await SubscribeHistory.async_get(self._db, history_id) + + async def async_delete(self, history_id: int) -> None: + """异步删除订阅历史。""" + await SubscribeHistory.async_delete(self._db, history_id) diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index c8e3da4ad..39979c27e 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -32,12 +32,18 @@ class TransferHistoryOper(DbOper): page: int = 1, count: int = 30, status: Optional[bool] = None, + wildcard: bool = False, ) -> List[TransferHistory]: """ 异步按标题分页查询转移记录。 """ return await TransferHistory.async_list_by_title( - self._db, title=title, page=page, count=count, status=status + self._db, + title=title, + page=page, + count=count, + status=status, + wildcard=wildcard, ) async def async_list_by_page( @@ -63,12 +69,16 @@ class TransferHistoryOper(DbOper): self, title: str, status: Optional[bool] = None, + wildcard: bool = False, ) -> Optional[int]: """ 异步按标题统计转移记录数量。 """ return await TransferHistory.async_count_by_title( - self._db, title=title, status=status + self._db, + title=title, + status=status, + wildcard=wildcard, ) def get_by_title(self, title: str) -> List[TransferHistory]: @@ -174,6 +184,14 @@ class TransferHistoryOper(DbOper): """ return TransferHistory.statistic(self._db, days) + async def async_statistic(self, days: int = 7) -> List[Any]: + """异步统计最近若干天的整理历史数量。""" + return await TransferHistory.async_statistic(self._db, days) + + def monthly_media_statistics(self) -> tuple[int, int, int, int]: + """统计本月成功整理的电影、剧集、单集和音乐数量。""" + return TransferHistory.monthly_media_statistics(self._db) + def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, diff --git a/app/db/oper/user.py b/app/db/oper/user.py index 595210e7f..eea7b5833 100644 --- a/app/db/oper/user.py +++ b/app/db/oper/user.py @@ -39,6 +39,38 @@ class UserOper(DbOper): """ return User.get_by_name(self._db, name) + def get_by_id(self, user_id: int) -> Optional[User]: + """按 ID 获取用户。""" + return User.get_by_id(self._db, user_id) + + async def async_list(self) -> List[User]: + """异步获取用户列表。""" + return await User.async_list(self._db) + + async def async_create(self, payload: dict) -> Optional[User]: + """异步创建用户。""" + return await User(**payload).async_create(self._db) + + async def async_update(self, user_id: int, payload: dict) -> Optional[User]: + """异步更新用户。""" + user = await self.async_get_by_id(user_id) + if user: + await user.async_update(self._db, payload) + return user + + async def async_delete(self, user_id: int) -> None: + """异步删除用户。""" + await User.async_delete_by_id(self._db, user_id) + + async def async_update_otp_by_name( + self, + name: str, + otp: bool, + secret: str, + ) -> None: + """异步更新用户 OTP 状态。""" + await User.async_update_otp_by_name(self._db, name, otp, secret) + async def async_get_by_name(self, name: str) -> Optional[User]: """ 异步根据用户名获取用户。 diff --git a/app/doctor/__init__.py b/app/doctor/__init__.py index 05ae4e567..d574ec077 100644 --- a/app/doctor/__init__.py +++ b/app/doctor/__init__.py @@ -1,12 +1,30 @@ -from app.doctor.models import DoctorFinding, DoctorReport -from app.doctor.runner import DoctorRunner +"""MoviePilot 离线诊断公开门面,具体对象按需解析。""" + +from importlib import import_module +from typing import Any -def run_doctor(*, fix: bool = False, deep: bool = False) -> DoctorReport: - """ - 运行 MoviePilot 离线诊断并返回报告。 - """ - return DoctorRunner(fix=fix, deep=deep).run() +_EXPORT_MODULES = { + "DoctorFinding": "app.doctor.models", + "DoctorReport": "app.doctor.models", + "DoctorRunner": "app.doctor.runner", + "run_doctor": "app.doctor.runner", +} + + +def __getattr__(name: str) -> Any: + """首次访问公开诊断对象时只加载其所属实现模块。""" + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module 'app.doctor' has no attribute {name!r}") + value = getattr(import_module(module_name), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """让惰性公开对象继续支持交互式发现。""" + return sorted(set(globals()) | set(_EXPORT_MODULES)) __all__ = [ diff --git a/app/doctor/runner.py b/app/doctor/runner.py index f1c88346c..350039167 100644 --- a/app/doctor/runner.py +++ b/app/doctor/runner.py @@ -116,3 +116,8 @@ class DoctorRunner: "safe_mode": settings.MOVIEPILOT_SAFE_MODE, "pid": os.getpid(), } + + +def run_doctor(*, fix: bool = False, deep: bool = False) -> DoctorReport: + """运行 MoviePilot 离线诊断并返回报告。""" + return DoctorRunner(fix=fix, deep=deep).run() diff --git a/app/factory.py b/app/factory.py index 2d103c1fb..f60245ba5 100644 --- a/app/factory.py +++ b/app/factory.py @@ -8,7 +8,15 @@ from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException from app.api.response import ResponseAPIRoute -from app.application.plugins import register_api_app +from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry +from app.application.plugins import configure_plugin_routes +from app.adapters.web.security.access import ( + configure_token_codec, + verify_apikey, + verify_token, +) +from app.application.security.token import create_access_token, decode_access_token +from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.config import settings from app.runtime.localization import LocaleHelper from app.runtime.log import logger @@ -325,9 +333,26 @@ def create_app() -> FastAPI: return _app +# HTTP 适配器只持有令牌编解码端口,具体实现由组合根连接。 +configure_token_codec(create_access_token, decode_access_token) + # 创建 FastAPI 应用实例 app = create_app() # 向 application 层插件路由服务注入应用实例,插件 API 的动态注册/移除 # 统一经服务完成,避免 api.endpoints 反向依赖本模块。 -register_api_app(app) +configure_plugin_routes(FastAPIDynamicRouteRegistry( + app=app, + plugin_ids=lambda: PluginManager().get_running_plugin_ids(), + plugin_apis=lambda plugin_id: PluginManager().get_plugin_apis(plugin_id), + verify_token=verify_token, + verify_apikey=verify_apikey, + prefix=f"{settings.API_V1_STR}/plugin", + protected_routes={ + f"{settings.API_V1_STR}/openapi.json", + "/docs", + "/docs/oauth2-redirect", + "/redoc", + }, + log=logger, +)) diff --git a/app/foundation/environment.py b/app/foundation/environment.py new file mode 100644 index 000000000..eeb72e3f5 --- /dev/null +++ b/app/foundation/environment.py @@ -0,0 +1,78 @@ +"""不依赖运行时和适配器的宿主环境探测原语。""" + +import os +import platform +import sys +from pathlib import Path +from typing import Optional + + +def is_docker() -> bool: + """判断当前进程是否运行在约定的 Docker 环境中。""" + return Path("/.dockerenv").exists() + + +def is_frozen() -> bool: + """判断当前 Python 进程是否为冻结二进制。""" + return bool(getattr(sys, "frozen", False)) + + +def is_windows() -> bool: + """判断当前操作系统是否为 Windows。""" + return os.name == "nt" + + +def is_macos() -> bool: + """判断当前操作系统是否为 macOS。""" + return platform.system() == "Darwin" + + +def is_aarch64() -> bool: + """判断当前 CPU 是否属于 64 位 ARM 架构。""" + return platform.machine().lower() in {"aarch64", "arm64"} + + +def is_aarch() -> bool: + """判断当前 CPU 是否属于非 64 位 ARM 架构。""" + arch_name = platform.machine().lower() + return arch_name.startswith(("arm", "aarch")) and not is_aarch64() + + +def is_x86_64() -> bool: + """判断当前 CPU 是否属于 64 位 x86 架构。""" + return platform.machine().lower() in {"amd64", "x86_64"} + + +def is_x86_32() -> bool: + """判断当前 CPU 是否属于 32 位 x86 架构。""" + return platform.machine().lower() in {"i386", "i686", "x86", "386", "x86_32"} + + +def cpu_arch() -> str: + """返回 MoviePilot 既有合同使用的 CPU 架构名称。""" + if is_x86_64(): + return "x86_64" + if is_x86_32(): + return "x86_32" + if is_aarch64(): + return "Arm64" + if is_aarch(): + return "Arm32" + return platform.machine() + + +def get_config_path(config_dir: Optional[str] = None) -> Path: + """按显式目录、容器、冻结进程和源码运行顺序确定配置目录。""" + configured = config_dir or os.getenv("CONFIG_DIR") + if configured: + return Path(configured) + if is_docker(): + return Path("/config") + if is_frozen(): + return Path(sys.executable).parent / "config" + return Path(__file__).resolve().parents[2] / "config" + + +def get_env_path(config_dir: Optional[str] = None) -> Path: + """返回给定运行环境对应的 ``app.env`` 文件路径。""" + return get_config_path(config_dir) / "app.env" diff --git a/app/modules/feishu/feishu.py b/app/modules/feishu/feishu.py index e78947b6d..f20b0380d 100644 --- a/app/modules/feishu/feishu.py +++ b/app/modules/feishu/feishu.py @@ -52,7 +52,7 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import ( from app.runtime.config import settings from app.domain.context import Context, MediaInfo -from app.db.oper.user import UserOper +from app.application.security.user import get_configured_user_channel_lookup from app.application.messaging.agent import matches_channel_admin from app.runtime.log import logger from app.schemas.message import IncomingMessage @@ -61,6 +61,15 @@ from app.schemas.types import NotificationChannel, MessageType from app.adapters.network.http import RequestUtils +class UserOper: + """兼容飞书模块存量测试的渠道用户查询门面。""" + + @staticmethod + def get_name(**bindings) -> Optional[str]: + """把渠道标识查询转发到启动组合根登记的用户端口。""" + return get_configured_user_channel_lookup()(**bindings) + + class Feishu: """飞书通知客户端,负责长连接收消息与主动发送通知。""" diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index c8ed6d76e..f04763817 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -2,7 +2,8 @@ from datetime import datetime from typing import List, Optional, Tuple, Union from app.domain.context import Context, SubtitleInfo, TorrentInfo -from app.db.oper.site import SiteOper +from app.application.site.health import get_configured_site_health_service +from app.application.site.query import get_configured_site_query_service from app.foundation.reflection import ModuleHelper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger @@ -103,7 +104,7 @@ class IndexerModule(_ModuleBase): torrent = context.torrent_info if torrent.site is None: return None - site = SiteOper().get(torrent.site) + site = get_configured_site_query_service().get_sync(torrent.site) if not site: return None indexer = SitesHelper().get_indexer(site.domain) @@ -156,9 +157,12 @@ class IndexerModule(_ModuleBase): """ domain = site_rules.extract_domain(site.get("domain")) if error_flag: - SiteOper().fail(domain) + get_configured_site_health_service().fail(domain) else: - SiteOper().success(domain=domain, seconds=seconds) + get_configured_site_health_service().success( + domain=domain, + seconds=seconds, + ) @staticmethod async def __async_indexer_statistic(site: dict, error_flag: bool = False, seconds: int = 0) -> None: @@ -167,9 +171,12 @@ class IndexerModule(_ModuleBase): """ domain = site_rules.extract_domain(site.get("domain")) if error_flag: - await SiteOper().async_fail(domain) + await get_configured_site_health_service().async_fail(domain) else: - await SiteOper().async_success(domain=domain, seconds=seconds) + await get_configured_site_health_service().async_success( + domain=domain, + seconds=seconds, + ) @staticmethod def __parse_result(site: dict, result_array: list, seconds: int) -> TorrentInfo: diff --git a/app/modules/indexer/spider/haidan.py b/app/modules/indexer/spider/haidan.py index 6f1bbac54..5ebba3885 100644 --- a/app/modules/indexer/spider/haidan.py +++ b/app/modules/indexer/spider/haidan.py @@ -2,7 +2,7 @@ import urllib.parse from typing import Tuple, List from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils diff --git a/app/modules/indexer/spider/hddolby.py b/app/modules/indexer/spider/hddolby.py index 9ef06a523..1b60c3488 100644 --- a/app/modules/indexer/spider/hddolby.py +++ b/app/modules/indexer/spider/hddolby.py @@ -1,7 +1,7 @@ from typing import Tuple, List, Optional from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils diff --git a/app/modules/indexer/spider/mtorrent.py b/app/modules/indexer/spider/mtorrent.py index f19c2616d..4e4b8023c 100644 --- a/app/modules/indexer/spider/mtorrent.py +++ b/app/modules/indexer/spider/mtorrent.py @@ -5,7 +5,7 @@ from typing import Tuple, List, Optional from urllib.parse import urlparse from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils diff --git a/app/modules/indexer/spider/rousi.py b/app/modules/indexer/spider/rousi.py index 597eafad3..342734ced 100644 --- a/app/modules/indexer/spider/rousi.py +++ b/app/modules/indexer/spider/rousi.py @@ -3,7 +3,7 @@ import json from typing import List, Optional, Tuple from app.runtime.config import settings -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.runtime.log import logger from app.schemas.types import MediaType from app.adapters.network.http import RequestUtils, AsyncRequestUtils diff --git a/app/modules/postgresql/__init__.py b/app/modules/postgresql/__init__.py index 6e45a85ad..c2223da94 100644 --- a/app/modules/postgresql/__init__.py +++ b/app/modules/postgresql/__init__.py @@ -1,10 +1,9 @@ from typing import Tuple, Union from app.runtime.config import settings -from app.db import SessionFactory +from app.application.database import get_configured_database_health from app.modules import _ModuleBase from app.schemas.types import ModuleType, OtherModulesType -from sqlalchemy import text class PostgreSQLModule(_ModuleBase): @@ -52,12 +51,7 @@ class PostgreSQLModule(_ModuleBase): """ if settings.DB_TYPE != "postgresql": return None - # 测试数据库连接 - db = SessionFactory() - try: - db.execute(text("SELECT 1")) - except Exception as e: - return False, f"PostgreSQL连接失败:{e}" - finally: - db.close() + error = get_configured_database_health().test() + if error: + return False, f"PostgreSQL连接失败:{error}" return True, "" diff --git a/app/modules/qqbot/__init__.py b/app/modules/qqbot/__init__.py index 899e18c43..5e71cebb7 100644 --- a/app/modules/qqbot/__init__.py +++ b/app/modules/qqbot/__init__.py @@ -1,422 +1,31 @@ -""" -QQ Bot 通知模块 -基于 QQ 开放平台,支持主动消息推送和 Gateway 接收消息 -注意:用户/群需曾与机器人交互过才能收到主动消息,且每月有配额限制 -""" +"""QQ Bot 宿主模块的惰性兼容入口。""" -import json -from urllib.parse import quote, unquote -from typing import Optional, List, Tuple, Union, Any - -from app.domain.context import MediaInfo, Context -from app.application.messaging.agent import ( - matches_channel_admin, - register_channel_admin_resolver, - resolve_config_principal_ids, -) -from app.runtime.log import logger -from app.modules._base import _MessageChannelModuleBase -from app.modules.qqbot.qqbot import QQBot -from app.schemas.message import IncomingMessage -from app.schemas.notification import NotificationChannel -from app.schemas.message import Message -from app.schemas.types import ModuleType -from app.adapters.network.http import RequestUtils +from importlib import import_module +from typing import Any -register_channel_admin_resolver( - NotificationChannel.QQ, - lambda config: resolve_config_principal_ids( - config, "QQBOT_ADMINS", "QQ_OPENID" - ), -) +_EXPORTS = { + "QQBot": ("app.modules.qqbot.qqbot", "QQBot"), + "QQBotModule": ("app.modules.qqbot.module", "QQBotModule"), +} -class QQBotModule(_MessageChannelModuleBase[QQBot]): - """QQ Bot 通知模块""" +def __getattr__(name: str) -> Any: + """按需解析历史包级导出,并保持模块类的原始反射路径。""" + contract = _EXPORTS.get(name) + if contract is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, symbol_name = contract + value = getattr(import_module(module_name), symbol_name) + if name == "QQBotModule": + value.__module__ = __name__ + globals()[name] = value + return value - # 管理员配置键,与渠道 resolver 保持一致 - _admin_config_key = "QQBOT_ADMINS" - _IMAGE_SUFFIXES = ( - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".tiff", - ".svg", - ) - _AUDIO_SUFFIXES = ( - ".mp3", - ".m4a", - ".wav", - ".ogg", - ".oga", - ".opus", - ".aac", - ".amr", - ".flac", - ".mpga", - ".mpeg", - ".webm", - ) +def __dir__() -> list[str]: + """向交互式工具公开兼容符号而不提前加载实现。""" + return sorted({*globals(), *_EXPORTS}) - def init_module(self) -> None: - super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot) - self._channel = NotificationChannel.QQ - @staticmethod - def get_name() -> str: - return "QQ" - - @staticmethod - def get_type() -> ModuleType: - return ModuleType.Notification - - @staticmethod - def get_subtype() -> NotificationChannel: - return NotificationChannel.QQ - - @staticmethod - def get_priority() -> int: - return 10 - - def _commands_enabled(self, config: Optional[dict]) -> bool: - """ - QQ 机器人客户端未提供命令注册/删除 API,跳过命令注册, - 避免基类默认钩子调用不存在的 client.register_commands。 - """ - return False - - def stop(self) -> None: - """停止模块""" - for client in self.get_instances().values(): - try: - client.stop() - except Exception as err: - logger.error(f"停止QQ Bot模块实例失败:{err}") - - def init_setting(self) -> Tuple[str, Union[str, bool]]: - pass - - @staticmethod - def _send_admin_denied( - client: Optional[QQBot], userid: Optional[Union[str, int]] - ) -> None: - """ - 向 QQ 非管理员用户发送命令拒绝提示。 - """ - if client and userid: - client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid)) - - def message_parser( - self, source: str, body: Any, form: Any, args: Any - ) -> Optional[IncomingMessage]: - """ - 解析 Gateway 转发的 QQ 消息 - body 格式: {"type": "C2C_MESSAGE_CREATE"|"GROUP_AT_MESSAGE_CREATE", "content": "...", "author": {...}, "id": "...", ...} - """ - client_config = self.get_config(source) - if not client_config: - return None - client: QQBot = self.get_instance(client_config.name) - try: - if isinstance(body, bytes): - msg_body = json.loads(body) - elif isinstance(body, dict): - msg_body = body - else: - return None - except (json.JSONDecodeError, TypeError) as err: - logger.debug(f"解析 QQ 消息失败: {err}") - return None - - msg_type = msg_body.get("type") - content = (msg_body.get("content") or "").strip() - images = self._extract_images(msg_body) - audio_refs = self._extract_audio_refs(msg_body) - files = self._extract_files(msg_body) - if not content and not images and not audio_refs and not files: - return None - - if msg_type == "C2C_MESSAGE_CREATE": - author = msg_body.get("author", {}) - user_openid = author.get("user_openid", "") - if not user_openid: - return None - if content.startswith("/") and self._should_reject_admin_command( - client_config.config, user_openid - ): - self._send_admin_denied(client, user_openid) - return None - logger.info( - f"收到 QQ 私聊消息: userid={user_openid}, " - f"text={(content or '')[:50]}..., images={len(images) if images else 0}, " - f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" - ) - return IncomingMessage( - channel=NotificationChannel.QQ, - source=client_config.name, - userid=user_openid, - username=user_openid, - is_channel_admin=matches_channel_admin( - NotificationChannel.QQ, - client_config.config, - user_openid, - ), - text=content, - images=images, - audio_refs=audio_refs, - files=files, - ) - elif msg_type == "GROUP_AT_MESSAGE_CREATE": - author = msg_body.get("author", {}) - member_openid = author.get("member_openid", "") - group_openid = msg_body.get("group_openid", "") - # 群聊用 group:group_openid 作为 userid,便于回复时识别 - userid = f"group:{group_openid}" if group_openid else member_openid - if content.startswith("/") and self._should_reject_admin_command( - client_config.config, member_openid - ): - self._send_admin_denied(client, userid) - return None - logger.info( - f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, " - f"text={(content or '')[:50]}..., images={len(images) if images else 0}, " - f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" - ) - return IncomingMessage( - channel=NotificationChannel.QQ, - source=client_config.name, - userid=userid, - username=member_openid or group_openid, - is_channel_admin=matches_channel_admin( - NotificationChannel.QQ, - client_config.config, - member_openid, - ), - text=content, - images=images, - audio_refs=audio_refs, - files=files, - ) - return None - - @classmethod - def _extract_images( - cls, msg_body: dict - ) -> Optional[List[IncomingMessage.MessageImage]]: - images: List[IncomingMessage.MessageImage] = [] - attachments = msg_body.get("attachments") or [] - if isinstance(attachments, list): - for attachment in attachments: - if not isinstance(attachment, dict): - continue - url = attachment.get("url") or attachment.get("proxy_url") - if not url: - continue - content_type = ( - attachment.get("content_type") - or attachment.get("mime_type") - or "" - ).lower() - filename = ( - attachment.get("filename") - or attachment.get("name") - or "" - ).lower() - if content_type.startswith("image/") or filename.endswith(cls._IMAGE_SUFFIXES): - images.append( - IncomingMessage.MessageImage( - ref=url, - name=attachment.get("filename") or attachment.get("name"), - mime_type=attachment.get("content_type") - or attachment.get("mime_type"), - size=attachment.get("size"), - ) - ) - - for key in ("image", "image_url", "pic_url"): - value = msg_body.get(key) - if isinstance(value, str) and value.startswith("http"): - images.append(IncomingMessage.MessageImage(ref=value)) - - extra_images = msg_body.get("images") - if isinstance(extra_images, list): - for item in extra_images: - if isinstance(item, str) and item.startswith("http"): - images.append(IncomingMessage.MessageImage(ref=item)) - elif isinstance(item, dict): - url = item.get("url") or item.get("image_url") - if isinstance(url, str) and url.startswith("http"): - images.append( - IncomingMessage.MessageImage( - ref=url, - name=item.get("name") or item.get("filename"), - mime_type=item.get("content_type") - or item.get("mime_type"), - size=item.get("size"), - ) - ) - - deduped = [] - for image in images: - if image.ref not in [item.ref for item in deduped]: - deduped.append(image) - return deduped or None - - @classmethod - def _extract_audio_refs(cls, msg_body: dict) -> Optional[List[str]]: - audio_refs: List[str] = [] - attachments = msg_body.get("attachments") or [] - if isinstance(attachments, list): - for attachment in attachments: - if not isinstance(attachment, dict): - continue - url = attachment.get("url") or attachment.get("proxy_url") - if not url: - continue - content_type = ( - attachment.get("content_type") - or attachment.get("mime_type") - or "" - ).lower() - filename = ( - attachment.get("filename") - or attachment.get("name") - or "" - ).lower() - if content_type.startswith("audio/") or filename.endswith(cls._AUDIO_SUFFIXES): - audio_refs.append(f"qq://file/{quote(url, safe='')}") - - deduped = [] - for audio_ref in audio_refs: - if audio_ref not in deduped: - deduped.append(audio_ref) - return deduped or None - - @classmethod - def _extract_files( - cls, msg_body: dict - ) -> Optional[List[IncomingMessage.MessageAttachment]]: - files: List[IncomingMessage.MessageAttachment] = [] - attachments = msg_body.get("attachments") or [] - if isinstance(attachments, list): - for attachment in attachments: - if not isinstance(attachment, dict): - continue - url = attachment.get("url") or attachment.get("proxy_url") - if not url: - continue - content_type = ( - attachment.get("content_type") - or attachment.get("mime_type") - or "" - ).lower() - filename = ( - attachment.get("filename") or attachment.get("name") or "" - ).lower() - is_image = content_type.startswith("image/") or filename.endswith( - cls._IMAGE_SUFFIXES - ) - is_audio = content_type.startswith("audio/") or filename.endswith( - cls._AUDIO_SUFFIXES - ) - if is_image or is_audio: - continue - files.append( - IncomingMessage.MessageAttachment( - ref=f"qq://file/{quote(url, safe='')}", - name=attachment.get("filename") or attachment.get("name"), - mime_type=attachment.get("content_type") - or attachment.get("mime_type"), - size=attachment.get("size"), - ) - ) - return files or None - - def download_qq_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]: - """ - 下载QQ音频附件并返回原始字节 - """ - if not file_ref or not file_ref.startswith("qq://file/"): - return None - if not self.get_config(source): - return None - file_url = unquote(file_ref.replace("qq://file/", "", 1)) - resp = RequestUtils(timeout=30).get_res(file_url) - if resp and resp.content: - return resp.content - return None - - def post_message(self, message: Message, **kwargs) -> None: - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - targets = message.targets - userid = message.userid - if not userid and targets: - userid = targets.get("qq_userid") or targets.get("qq_openid") - if not userid: - userid = targets.get("qq_group_openid") or targets.get("qq_group") - if userid: - userid = f"group:{userid}" - # 无 userid 且无默认配置时,由 client 向曾发过消息的用户/群广播 - client: QQBot = self.get_instance(conf.name) - if client: - client.send_msg( - title=message.title, - text=message.text, - image=message.image, - link=message.link, - userid=userid, - targets=targets, - ) - - def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None: - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - targets = message.targets - userid = message.userid - if not userid and targets: - userid = targets.get("qq_userid") or targets.get("qq_openid") - if not userid: - g = targets.get("qq_group_openid") or targets.get("qq_group") - if g: - userid = f"group:{g}" - client: QQBot = self.get_instance(conf.name) - if client: - client.send_medias_msg( - medias=medias, - userid=userid, - title=message.title, - link=message.link, - targets=targets, - ) - - def post_torrents_message( - self, message: Message, torrents: List[Context] - ) -> None: - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - targets = message.targets - userid = message.userid - if not userid and targets: - userid = targets.get("qq_userid") or targets.get("qq_openid") - if not userid: - g = targets.get("qq_group_openid") or targets.get("qq_group") - if g: - userid = f"group:{g}" - client: QQBot = self.get_instance(conf.name) - if client: - client.send_torrents_msg( - torrents=torrents, - userid=userid, - title=message.title, - link=message.link, - targets=targets, - ) +__all__ = ["QQBot", "QQBotModule"] diff --git a/app/modules/qqbot/module.py b/app/modules/qqbot/module.py new file mode 100644 index 000000000..899e18c43 --- /dev/null +++ b/app/modules/qqbot/module.py @@ -0,0 +1,422 @@ +""" +QQ Bot 通知模块 +基于 QQ 开放平台,支持主动消息推送和 Gateway 接收消息 +注意:用户/群需曾与机器人交互过才能收到主动消息,且每月有配额限制 +""" + +import json +from urllib.parse import quote, unquote +from typing import Optional, List, Tuple, Union, Any + +from app.domain.context import MediaInfo, Context +from app.application.messaging.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) +from app.runtime.log import logger +from app.modules._base import _MessageChannelModuleBase +from app.modules.qqbot.qqbot import QQBot +from app.schemas.message import IncomingMessage +from app.schemas.notification import NotificationChannel +from app.schemas.message import Message +from app.schemas.types import ModuleType +from app.adapters.network.http import RequestUtils + + +register_channel_admin_resolver( + NotificationChannel.QQ, + lambda config: resolve_config_principal_ids( + config, "QQBOT_ADMINS", "QQ_OPENID" + ), +) + + +class QQBotModule(_MessageChannelModuleBase[QQBot]): + """QQ Bot 通知模块""" + + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "QQBOT_ADMINS" + + _IMAGE_SUFFIXES = ( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".tiff", + ".svg", + ) + _AUDIO_SUFFIXES = ( + ".mp3", + ".m4a", + ".wav", + ".ogg", + ".oga", + ".opus", + ".aac", + ".amr", + ".flac", + ".mpga", + ".mpeg", + ".webm", + ) + + def init_module(self) -> None: + super().init_service(service_name=QQBot.__name__.lower(), service_type=QQBot) + self._channel = NotificationChannel.QQ + + @staticmethod + def get_name() -> str: + return "QQ" + + @staticmethod + def get_type() -> ModuleType: + return ModuleType.Notification + + @staticmethod + def get_subtype() -> NotificationChannel: + return NotificationChannel.QQ + + @staticmethod + def get_priority() -> int: + return 10 + + def _commands_enabled(self, config: Optional[dict]) -> bool: + """ + QQ 机器人客户端未提供命令注册/删除 API,跳过命令注册, + 避免基类默认钩子调用不存在的 client.register_commands。 + """ + return False + + def stop(self) -> None: + """停止模块""" + for client in self.get_instances().values(): + try: + client.stop() + except Exception as err: + logger.error(f"停止QQ Bot模块实例失败:{err}") + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + pass + + @staticmethod + def _send_admin_denied( + client: Optional[QQBot], userid: Optional[Union[str, int]] + ) -> None: + """ + 向 QQ 非管理员用户发送命令拒绝提示。 + """ + if client and userid: + client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid)) + + def message_parser( + self, source: str, body: Any, form: Any, args: Any + ) -> Optional[IncomingMessage]: + """ + 解析 Gateway 转发的 QQ 消息 + body 格式: {"type": "C2C_MESSAGE_CREATE"|"GROUP_AT_MESSAGE_CREATE", "content": "...", "author": {...}, "id": "...", ...} + """ + client_config = self.get_config(source) + if not client_config: + return None + client: QQBot = self.get_instance(client_config.name) + try: + if isinstance(body, bytes): + msg_body = json.loads(body) + elif isinstance(body, dict): + msg_body = body + else: + return None + except (json.JSONDecodeError, TypeError) as err: + logger.debug(f"解析 QQ 消息失败: {err}") + return None + + msg_type = msg_body.get("type") + content = (msg_body.get("content") or "").strip() + images = self._extract_images(msg_body) + audio_refs = self._extract_audio_refs(msg_body) + files = self._extract_files(msg_body) + if not content and not images and not audio_refs and not files: + return None + + if msg_type == "C2C_MESSAGE_CREATE": + author = msg_body.get("author", {}) + user_openid = author.get("user_openid", "") + if not user_openid: + return None + if content.startswith("/") and self._should_reject_admin_command( + client_config.config, user_openid + ): + self._send_admin_denied(client, user_openid) + return None + logger.info( + f"收到 QQ 私聊消息: userid={user_openid}, " + f"text={(content or '')[:50]}..., images={len(images) if images else 0}, " + f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" + ) + return IncomingMessage( + channel=NotificationChannel.QQ, + source=client_config.name, + userid=user_openid, + username=user_openid, + is_channel_admin=matches_channel_admin( + NotificationChannel.QQ, + client_config.config, + user_openid, + ), + text=content, + images=images, + audio_refs=audio_refs, + files=files, + ) + elif msg_type == "GROUP_AT_MESSAGE_CREATE": + author = msg_body.get("author", {}) + member_openid = author.get("member_openid", "") + group_openid = msg_body.get("group_openid", "") + # 群聊用 group:group_openid 作为 userid,便于回复时识别 + userid = f"group:{group_openid}" if group_openid else member_openid + if content.startswith("/") and self._should_reject_admin_command( + client_config.config, member_openid + ): + self._send_admin_denied(client, userid) + return None + logger.info( + f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, " + f"text={(content or '')[:50]}..., images={len(images) if images else 0}, " + f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" + ) + return IncomingMessage( + channel=NotificationChannel.QQ, + source=client_config.name, + userid=userid, + username=member_openid or group_openid, + is_channel_admin=matches_channel_admin( + NotificationChannel.QQ, + client_config.config, + member_openid, + ), + text=content, + images=images, + audio_refs=audio_refs, + files=files, + ) + return None + + @classmethod + def _extract_images( + cls, msg_body: dict + ) -> Optional[List[IncomingMessage.MessageImage]]: + images: List[IncomingMessage.MessageImage] = [] + attachments = msg_body.get("attachments") or [] + if isinstance(attachments, list): + for attachment in attachments: + if not isinstance(attachment, dict): + continue + url = attachment.get("url") or attachment.get("proxy_url") + if not url: + continue + content_type = ( + attachment.get("content_type") + or attachment.get("mime_type") + or "" + ).lower() + filename = ( + attachment.get("filename") + or attachment.get("name") + or "" + ).lower() + if content_type.startswith("image/") or filename.endswith(cls._IMAGE_SUFFIXES): + images.append( + IncomingMessage.MessageImage( + ref=url, + name=attachment.get("filename") or attachment.get("name"), + mime_type=attachment.get("content_type") + or attachment.get("mime_type"), + size=attachment.get("size"), + ) + ) + + for key in ("image", "image_url", "pic_url"): + value = msg_body.get(key) + if isinstance(value, str) and value.startswith("http"): + images.append(IncomingMessage.MessageImage(ref=value)) + + extra_images = msg_body.get("images") + if isinstance(extra_images, list): + for item in extra_images: + if isinstance(item, str) and item.startswith("http"): + images.append(IncomingMessage.MessageImage(ref=item)) + elif isinstance(item, dict): + url = item.get("url") or item.get("image_url") + if isinstance(url, str) and url.startswith("http"): + images.append( + IncomingMessage.MessageImage( + ref=url, + name=item.get("name") or item.get("filename"), + mime_type=item.get("content_type") + or item.get("mime_type"), + size=item.get("size"), + ) + ) + + deduped = [] + for image in images: + if image.ref not in [item.ref for item in deduped]: + deduped.append(image) + return deduped or None + + @classmethod + def _extract_audio_refs(cls, msg_body: dict) -> Optional[List[str]]: + audio_refs: List[str] = [] + attachments = msg_body.get("attachments") or [] + if isinstance(attachments, list): + for attachment in attachments: + if not isinstance(attachment, dict): + continue + url = attachment.get("url") or attachment.get("proxy_url") + if not url: + continue + content_type = ( + attachment.get("content_type") + or attachment.get("mime_type") + or "" + ).lower() + filename = ( + attachment.get("filename") + or attachment.get("name") + or "" + ).lower() + if content_type.startswith("audio/") or filename.endswith(cls._AUDIO_SUFFIXES): + audio_refs.append(f"qq://file/{quote(url, safe='')}") + + deduped = [] + for audio_ref in audio_refs: + if audio_ref not in deduped: + deduped.append(audio_ref) + return deduped or None + + @classmethod + def _extract_files( + cls, msg_body: dict + ) -> Optional[List[IncomingMessage.MessageAttachment]]: + files: List[IncomingMessage.MessageAttachment] = [] + attachments = msg_body.get("attachments") or [] + if isinstance(attachments, list): + for attachment in attachments: + if not isinstance(attachment, dict): + continue + url = attachment.get("url") or attachment.get("proxy_url") + if not url: + continue + content_type = ( + attachment.get("content_type") + or attachment.get("mime_type") + or "" + ).lower() + filename = ( + attachment.get("filename") or attachment.get("name") or "" + ).lower() + is_image = content_type.startswith("image/") or filename.endswith( + cls._IMAGE_SUFFIXES + ) + is_audio = content_type.startswith("audio/") or filename.endswith( + cls._AUDIO_SUFFIXES + ) + if is_image or is_audio: + continue + files.append( + IncomingMessage.MessageAttachment( + ref=f"qq://file/{quote(url, safe='')}", + name=attachment.get("filename") or attachment.get("name"), + mime_type=attachment.get("content_type") + or attachment.get("mime_type"), + size=attachment.get("size"), + ) + ) + return files or None + + def download_qq_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]: + """ + 下载QQ音频附件并返回原始字节 + """ + if not file_ref or not file_ref.startswith("qq://file/"): + return None + if not self.get_config(source): + return None + file_url = unquote(file_ref.replace("qq://file/", "", 1)) + resp = RequestUtils(timeout=30).get_res(file_url) + if resp and resp.content: + return resp.content + return None + + def post_message(self, message: Message, **kwargs) -> None: + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + targets = message.targets + userid = message.userid + if not userid and targets: + userid = targets.get("qq_userid") or targets.get("qq_openid") + if not userid: + userid = targets.get("qq_group_openid") or targets.get("qq_group") + if userid: + userid = f"group:{userid}" + # 无 userid 且无默认配置时,由 client 向曾发过消息的用户/群广播 + client: QQBot = self.get_instance(conf.name) + if client: + client.send_msg( + title=message.title, + text=message.text, + image=message.image, + link=message.link, + userid=userid, + targets=targets, + ) + + def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None: + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + targets = message.targets + userid = message.userid + if not userid and targets: + userid = targets.get("qq_userid") or targets.get("qq_openid") + if not userid: + g = targets.get("qq_group_openid") or targets.get("qq_group") + if g: + userid = f"group:{g}" + client: QQBot = self.get_instance(conf.name) + if client: + client.send_medias_msg( + medias=medias, + userid=userid, + title=message.title, + link=message.link, + targets=targets, + ) + + def post_torrents_message( + self, message: Message, torrents: List[Context] + ) -> None: + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + targets = message.targets + userid = message.userid + if not userid and targets: + userid = targets.get("qq_userid") or targets.get("qq_openid") + if not userid: + g = targets.get("qq_group_openid") or targets.get("qq_group") + if g: + userid = f"group:{g}" + client: QQBot = self.get_instance(conf.name) + if client: + client.send_torrents_msg( + torrents=torrents, + userid=userid, + title=message.title, + link=message.link, + targets=targets, + ) diff --git a/app/modules/subtitle/__init__.py b/app/modules/subtitle/__init__.py index d1503d634..c4d51fb22 100644 --- a/app/modules/subtitle/__init__.py +++ b/app/modules/subtitle/__init__.py @@ -6,7 +6,7 @@ from lxml import etree from app.runtime.config import settings from app.domain.context import Context -from app.db.oper.site import SiteOper +from app.application.site.query import get_configured_site_query_service from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger from app.modules import _ModuleBase @@ -137,7 +137,7 @@ class SubtitleModule(_ModuleBase): return None # 采用API访问的站点由对应爬虫模块处理,详情页HTML不含字幕元素 if torrent.site is not None: - site = SiteOper().get(torrent.site) + site = get_configured_site_query_service().get_sync(torrent.site) if site and (indexer := SitesHelper().get_indexer(site.domain)): if indexer.get("parser") == "mTorrent": return None diff --git a/app/modules/telegram/__init__.py b/app/modules/telegram/__init__.py index 9901793c5..e63fceb52 100644 --- a/app/modules/telegram/__init__.py +++ b/app/modules/telegram/__init__.py @@ -1,784 +1,31 @@ -import json -import re -from typing import Optional, Union, List, Tuple, Any +"""Telegram 宿主模块的惰性兼容入口。""" -from app.domain.context import MediaInfo, Context -from app.application.messaging.agent import ( - matches_channel_admin, - register_channel_admin_resolver, - resolve_config_principal_ids, -) -from app.runtime.log import logger -from app.modules._base import _MessageChannelModuleBase -from app.modules.telegram.telegram import Telegram -from app.schemas.notification import NotificationChannel -from app.schemas.message import IncomingMessage -from app.schemas.message import Message -from app.schemas.system import NotificationConf -from app.schemas.message import MessageResponse -from app.schemas.types import ModuleType +from importlib import import_module +from typing import Any -register_channel_admin_resolver( - NotificationChannel.Telegram, - lambda config: resolve_config_principal_ids( - config, "TELEGRAM_ADMINS", "TELEGRAM_CHAT_ID" - ), -) +_EXPORTS = { + "Telegram": ("app.modules.telegram.telegram", "Telegram"), + "TelegramModule": ("app.modules.telegram.module", "TelegramModule"), +} -class TelegramModule(_MessageChannelModuleBase[Telegram]): - """ - Telegram 通知模块,负责模块生命周期、消息解析和通知发送。 - """ +def __getattr__(name: str) -> Any: + """按需解析历史包级导出,并保持模块类的原始反射路径。""" + contract = _EXPORTS.get(name) + if contract is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, symbol_name = contract + value = getattr(import_module(module_name), symbol_name) + if name == "TelegramModule": + value.__module__ = __name__ + globals()[name] = value + return value - # 管理员配置键,与渠道 resolver 保持一致 - _admin_config_key = "TELEGRAM_ADMINS" - def init_module(self) -> None: - """ - 初始化模块 - """ - super().init_service( - service_name=Telegram.__name__.lower(), service_type=Telegram - ) - self._channel = NotificationChannel.Telegram +def __dir__() -> list[str]: + """向交互式工具公开兼容符号而不提前加载实现。""" + return sorted({*globals(), *_EXPORTS}) - @staticmethod - def get_name() -> str: - """ - 获取模块名称 - """ - return "Telegram" - @staticmethod - def get_type() -> ModuleType: - """ - 获取模块类型 - """ - return ModuleType.Notification - - @staticmethod - def get_subtype() -> NotificationChannel: - """ - 获取模块子类型 - """ - return NotificationChannel.Telegram - - @staticmethod - def get_priority() -> int: - """ - 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 - """ - return 0 - - def stop(self) -> None: - """停止模块""" - for client in self.get_instances().values(): - try: - client.stop() - except Exception as err: - logger.error(f"停止Telegram模块实例失败:{err}") - - def init_setting(self) -> Tuple[str, Union[str, bool]]: - """ - 获取模块初始化配置项。 - """ - pass - - def message_parser( - self, source: str, body: Any, form: Any, args: Any - ) -> Optional[IncomingMessage]: - """ - 解析消息内容,返回字典,注意以下约定值: - userid: 用户ID - username: 用户名 - text: 内容 - :param source: 消息来源 - :param body: 请求体 - :param form: 表单 - :param args: 参数 - :return: 渠道、消息体 - """ - """ - 普通消息格式: - { - 'update_id': , - 'message': { - 'message_id': , - 'from': { - 'id': , - 'is_bot': False, - 'first_name': '', - 'username': '', - 'language_code': 'zh-hans' - }, - 'chat': { - 'id': , - 'first_name': '', - 'username': '', - 'type': 'private' - }, - 'date': , - 'text': '' - } - } - - 按钮回调格式: - { - 'callback_query': { - 'id': '', - 'from': {...}, - 'message': {...}, - 'data': 'callback_data' - } - } - """ - # 获取服务配置 - client_config = self.get_config(source) - if not client_config: - return None - client: Telegram = self.get_instance(client_config.name) - try: - message = json.loads(body) - while isinstance(message, str): - message = json.loads(message) - except Exception as err: - logger.debug(f"解析Telegram消息失败:{str(err)}") - return None - - if not isinstance(message, dict): - logger.debug(f"Telegram消息格式无效:{type(message)}") - return None - - # 兼容某些转发链路使用 Telegram Update 外壳 - if "message" in message and isinstance(message.get("message"), dict): - message = message.get("message") - - if message: - # 处理按钮回调 - if "callback_query" in message: - return self._handle_callback_query(message, client_config, client) - - # 处理普通消息 - return self._handle_text_message(message, client_config, client) - - return None - - def _handle_callback_query( - self, message: dict, client_config: NotificationConf, client: Telegram - ) -> Optional[IncomingMessage]: - """ - 处理按钮回调查询 - """ - callback_query = message.get("callback_query", {}) - user_info = callback_query.get("from", {}) - callback_data = callback_query.get("data", "") - user_id = user_info.get("id") - user_name = user_info.get("username") - - if callback_data and user_id: - if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( - client_config.config, user_id - ): - if client: - client.answer_callback_query( - callback_query_id=callback_query.get("id"), - text="只有管理员才有权限执行此命令", - show_alert=True, - ) - return None - - logger.info( - f"收到来自 {client_config.name} 的Telegram按钮回调:" - f"userid={user_id}, username={user_name}, callback_data={callback_data}" - ) - - # 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调 - callback_text = f"CALLBACK:{callback_data}" - - # 创建包含完整回调信息的CommingMessage - return IncomingMessage( - channel=NotificationChannel.Telegram, - source=client_config.name, - userid=user_id, - username=user_name, - is_channel_admin=matches_channel_admin( - NotificationChannel.Telegram, - client_config.config, - user_id, - ), - text=callback_text, - is_callback=True, - callback_data=callback_data, - message_id=callback_query.get("message", {}).get("message_id"), - chat_id=str( - callback_query.get("message", {}).get("chat", {}).get("id", "") - ), - callback_query=callback_query, - ) - return None - - def _handle_text_message( - self, msg: dict, client_config: NotificationConf, client: Telegram - ) -> Optional[IncomingMessage]: - """ - 处理普通文本消息 - """ - text = msg.get("text") or msg.get("caption") - message_id = msg.get("message_id") - user_id = msg.get("from", {}).get("id") - user_name = msg.get("from", {}).get("username") - chat_id = msg.get("chat", {}).get("id") - reply_to_message_id = (msg.get("reply_to_message") or {}).get("message_id") - - # 将 text_link 实体中的 URL 嵌入到文本中 - if text: - text = self._embed_entity_links(text, msg.get("entities") or msg.get("caption_entities")) - - # 将 reply_markup 中的 URL 按钮信息追加到文本中 - text = self._append_reply_markup_links(text, msg.get("reply_markup")) - - images = self._extract_images(msg) - audio_refs = self._extract_audio_refs(msg) - files = self._extract_files(msg) - - if user_id: - if not text and not images and not audio_refs and not files: - logger.debug( - f"收到来自 {client_config.name} 的Telegram消息无文本、图片、语音和文件" - ) - return None - - logger.info( - f"收到来自 {client_config.name} 的Telegram消息:" - f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}, " - f"images={len(images) if images else 0}, audios={len(audio_refs) if audio_refs else 0}, " - f"files={len(files) if files else 0}" - ) - - cleaned_text = ( - self._clean_bot_mention(text, client.bot_username if client else None) - if text - else None - ) - - user_list = client_config.config.get("TELEGRAM_USERS") - - if cleaned_text and cleaned_text.startswith("/"): - if self._should_reject_admin_command(client_config.config, user_id): - client.send_msg( - title="只有管理员才有权限执行此命令", userid=user_id - ) - return None - else: - if user_list and str(user_id) not in user_list.split(","): - logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人") - client.send_msg( - title="你不在用户白名单中,无法使用此机器人", userid=user_id - ) - return None - - return IncomingMessage( - channel=NotificationChannel.Telegram, - source=client_config.name, - userid=user_id, - username=user_name, - is_channel_admin=matches_channel_admin( - NotificationChannel.Telegram, - client_config.config, - user_id, - ), - text=cleaned_text, - message_id=message_id, - chat_id=str(chat_id) if chat_id else None, - reply_to_message_id=reply_to_message_id, - images=images if images else None, - audio_refs=audio_refs if audio_refs else None, - files=files if files else None, - ) - return None - - @staticmethod - def _extract_images(msg: dict) -> Optional[List[IncomingMessage.MessageImage]]: - """ - 从Telegram消息中提取图片file_id - """ - images = [] - photo = msg.get("photo") - if photo and isinstance(photo, list): - largest_photo = photo[-1] - file_id = largest_photo.get("file_id") - if file_id: - images.append( - IncomingMessage.MessageImage( - ref=f"tg://file_id/{file_id}", - mime_type="image/jpeg", - size=largest_photo.get("file_size"), - ) - ) - - document = msg.get("document") - if document: - file_id = document.get("file_id") - mime_type = document.get("mime_type", "") - if file_id and mime_type.startswith("image/"): - images.append( - IncomingMessage.MessageImage( - ref=f"tg://file_id/{file_id}", - name=document.get("file_name"), - mime_type=document.get("mime_type"), - size=document.get("file_size"), - ) - ) - - return images if images else None - - @staticmethod - def _extract_audio_refs(msg: dict) -> Optional[List[str]]: - """ - 从Telegram消息中提取语音/音频 file_id。 - """ - audio_refs = [] - voice = msg.get("voice") - if voice: - file_id = voice.get("file_id") - if file_id: - audio_refs.append(f"tg://voice_file_id/{file_id}") - - audio = msg.get("audio") - if audio: - file_id = audio.get("file_id") - if file_id: - audio_refs.append(f"tg://audio_file_id/{file_id}") - - return audio_refs if audio_refs else None - - @staticmethod - def _extract_files(msg: dict) -> Optional[List[IncomingMessage.MessageAttachment]]: - """ - 从 Telegram 消息中提取非图片文件附件。 - """ - document = msg.get("document") - if not isinstance(document, dict): - return None - - file_id = document.get("file_id") - mime_type = (document.get("mime_type") or "").lower() - if not file_id or mime_type.startswith("image/"): - return None - - return [ - IncomingMessage.MessageAttachment( - ref=f"tg://document_file_id/{file_id}", - name=document.get("file_name"), - mime_type=document.get("mime_type"), - size=document.get("file_size"), - ) - ] - - @staticmethod - def _embed_entity_links(text: str, entities: Optional[List[dict]]) -> str: - """ - 将 text_link 实体中的 URL 嵌入到文本中 - - :param text: 原始文本 - :param entities: 消息实体列表 - :return: 嵌入链接后的文本 - """ - if not entities: - return text - text_link_entities = sorted( - [e for e in entities if e.get("type") == "text_link" and e.get("url")], - key=lambda e: e.get("offset", 0), - reverse=True, - ) - text_utf16 = text.encode("utf-16-le") - for entity in text_link_entities: - offset = entity.get("offset", 0) - length = entity.get("length", 0) - url = entity["url"] - char_offset = len(text_utf16[:offset * 2].decode("utf-16-le")) - char_length = len(text_utf16[offset * 2: (offset + length) * 2].decode("utf-16-le")) - display_text = text[char_offset: char_offset + char_length] - text = text[:char_offset] + f"{display_text}({url})" + text[char_offset + char_length:] - text_utf16 = text.encode("utf-16-le") - return text - - @staticmethod - def _append_reply_markup_links(text: Optional[str], reply_markup: Optional[dict]) -> Optional[str]: - """ - 将 reply_markup 中的 URL 按钮信息追加到文本末尾 - - :param text: 原始文本 - :param reply_markup: 消息的 reply_markup 字段 - :return: 追加按钮链接后的文本 - """ - if not reply_markup: - return text - inline_keyboard = reply_markup.get("inline_keyboard") - if not inline_keyboard: - return text - button_lines = [] - for row in inline_keyboard: - for button in row: - btn_text = button.get("text", "") - btn_url = button.get("url") - if btn_url: - button_lines.append(f"{btn_text}({btn_url})") - if not button_lines: - return text - buttons_text = "\n".join(button_lines) - if text: - return f"{text}\n{buttons_text}" - return buttons_text - - @staticmethod - def _clean_bot_mention(text: str, bot_username: Optional[str]) -> str: - """ - 清理消息中的@bot部分,确保文本处理一致性 - :param text: 原始消息文本 - :param bot_username: bot用户名 - :return: 清理后的文本 - """ - if not text or not bot_username: - return text - - # Remove @bot_username from the beginning and any position in text - cleaned = text - mention_pattern = f"@{bot_username}" - - # Remove mention at the beginning with optional following space - if cleaned.startswith(mention_pattern): - cleaned = cleaned[len(mention_pattern):].lstrip() - - # Remove mention at any other position - cleaned = cleaned.replace(mention_pattern, "").strip() - - # Clean up multiple spaces - cleaned = re.sub(r"\s+", " ", cleaned).strip() - - return cleaned - - def post_message(self, message: Message, **kwargs) -> None: - """ - 发送消息 - :param message: 消息体 - :return: 成功或失败 - """ - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - targets = message.targets - userid = message.userid - if not userid and targets is not None: - userid = targets.get("telegram_userid") - if not userid: - logger.warn(f"用户没有指定 Telegram用户ID,消息无法发送") - return - client: Telegram = self.get_instance(conf.name) - if client: - if message.file_path: - client.send_file( - file_path=message.file_path, - file_name=message.file_name, - title=message.title, - text=message.text, - userid=userid, - original_chat_id=message.original_chat_id, - parse_mode=message.parse_mode, - ) - elif message.voice_path: - client.send_voice( - voice_path=message.voice_path, - userid=userid, - caption=message.voice_caption, - original_chat_id=message.original_chat_id, - parse_mode=message.parse_mode, - ) - else: - # Telegram 的 reply_markup 不能同时承载 InlineKeyboard 和 ForceReply。 - # 普通通知只清空可编辑消息 ID,仍保留原会话作为新消息目标。 - has_interaction_context = bool(message.buttons or message.force_reply) - original_message_id = ( - message.original_message_id if has_interaction_context else None - ) - client.send_msg( - title=message.title, - text=message.text, - image=message.image, - userid=userid, - link=message.link, - buttons=message.buttons, - force_reply=message.force_reply, - original_message_id=original_message_id, - original_chat_id=message.original_chat_id, - disable_web_page_preview=message.disable_web_page_preview, - parse_mode=message.parse_mode, - ) - - def post_medias_message( - self, message: Message, medias: List[MediaInfo] - ) -> None: - """ - 发送媒体信息选择列表 - :param message: 消息体 - :param medias: 媒体列表 - :return: 成功或失败 - """ - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - client: Telegram = self.get_instance(conf.name) - if client: - client.send_medias_msg( - title=message.title, - medias=medias, - userid=message.userid, - link=message.link, - buttons=message.buttons, - original_message_id=message.original_message_id, - original_chat_id=message.original_chat_id, - parse_mode=message.parse_mode, - ) - - def post_torrents_message( - self, message: Message, torrents: List[Context] - ) -> None: - """ - 发送种子信息选择列表 - :param message: 消息体 - :param torrents: 种子列表 - :return: 成功或失败 - """ - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - client: Telegram = self.get_instance(conf.name) - if client: - client.send_torrents_msg( - title=message.title, - torrents=torrents, - userid=message.userid, - link=message.link, - buttons=message.buttons, - original_message_id=message.original_message_id, - original_chat_id=message.original_chat_id, - parse_mode=message.parse_mode, - ) - - def delete_message( - self, - channel: NotificationChannel, - source: str, - message_id: int, - chat_id: Optional[int] = None, - ) -> Optional[bool]: - """ - 删除消息 - :param channel: 消息渠道 - :param source: 指定的消息源 - :param message_id: 消息ID - :param chat_id: 聊天ID - :return: 删除是否成功 - """ - if channel != self._channel: - return None - success = False - for conf in self.get_configs().values(): - if source != conf.name: - continue - client: Telegram = self.get_instance(conf.name) - if client: - result = client.delete_msg(message_id=message_id, chat_id=chat_id) - if result: - success = True - return success - - def edit_message( - self, - channel: NotificationChannel, - source: str, - message_id: Union[str, int], - chat_id: Union[str, int], - text: str, - title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, - metadata: Optional[dict] = None, - parse_mode: Optional[str] = None, - ) -> Optional[bool]: - """ - 编辑消息 - :param channel: 消息渠道 - :param source: 指定的消息源 - :param message_id: 消息ID - :param chat_id: 聊天ID - :param text: 新的消息内容 - :param title: 消息标题 - :param buttons: 新的按钮列表 - :param metadata: 其他元信息 - :param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML - :return: 编辑是否成功 - """ - if channel != self._channel: - return None - for conf in self.get_configs().values(): - if source != conf.name: - continue - client: Telegram = self.get_instance(conf.name) - if client: - result = client.edit_msg( - chat_id=chat_id, - message_id=message_id, - text=text, - title=title, - buttons=buttons, - parse_mode=parse_mode, - ) - if result: - return True - return False - - def mark_message_processing_started( - self, - channel: NotificationChannel, - source: str, - userid: Optional[Union[str, int]] = None, - message_id: Optional[Union[str, int]] = None, - chat_id: Optional[Union[str, int]] = None, - text: Optional[str] = None, - ) -> Optional[dict]: - """ - 标记 Telegram 消息正在处理。 - Telegram typing 需要周期性续发,因此在模块接口中启动保活任务。 - """ - if channel != self._channel: - return None - client_config = self.get_config(source) - if not client_config: - return None - client: Telegram = self.get_instance(client_config.name) - if not client: - return None - started = client.start_typing(chat_id=chat_id, userid=userid) - if not started: - return None - return { - "channel": channel.value, - "source": source, - "userid": userid, - "message_id": message_id, - "chat_id": chat_id, - "metadata": {"kind": "typing"}, - } - - def mark_message_processing_finished( - self, - channel: NotificationChannel, - source: str, - userid: Optional[Union[str, int]] = None, - message_id: Optional[Union[str, int]] = None, - chat_id: Optional[Union[str, int]] = None, - status: Optional[dict] = None, - ) -> Optional[bool]: - """ - 结束 Telegram typing 状态。 - """ - if channel != self._channel: - return None - if status: - chat_id = status.get("chat_id") or chat_id - userid = status.get("userid") or userid - client_config = self.get_config(source) - if not client_config: - return False - client: Telegram = self.get_instance(client_config.name) - if not client: - return False - return client.stop_typing(chat_id=chat_id, userid=userid) - - def send_direct_message(self, message: Message) -> Optional[MessageResponse]: - """ - 直接发送消息并返回消息ID等信息 - :param message: 消息体 - :return: 消息响应(包含message_id, chat_id等) - """ - for conf in self.get_configs().values(): - if not self.check_message(message, conf.name): - continue - targets = message.targets - userid = message.userid - if not userid and targets is not None: - userid = targets.get("telegram_userid") - if not userid: - logger.warn("用户没有指定 Telegram用户ID,消息无法发送") - return None - client: Telegram = self.get_instance(conf.name) - if client: - if message.voice_path: - result = client.send_voice( - voice_path=message.voice_path, - userid=userid, - caption=message.voice_caption, - original_chat_id=message.original_chat_id, - parse_mode=message.parse_mode, - ) - else: - # direct message 只禁用编辑旧消息;仅 ForceReply 使用 original_chat_id - # 发回原会话,并保留 original_message_id 让 client reply_to 原消息。 - original_chat_id = message.original_chat_id if message.force_reply else None - original_message_id = message.original_message_id if message.force_reply else None - result = client.send_msg( - title=message.title, - text=message.text, - image=message.image, - userid=userid, - link=message.link, - force_reply=message.force_reply, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - disable_web_page_preview=message.disable_web_page_preview, - parse_mode=message.parse_mode, - private_delivery=message.private_delivery, - ) - if result and result.get("success"): - return MessageResponse( - message_id=result.get("message_id"), - chat_id=result.get("chat_id"), - channel=NotificationChannel.Telegram, - source=conf.name, - success=True, - ) - return None - - def download_telegram_file_to_base64(self, file_id: str, source: str) -> Optional[str]: - """ - 下载Telegram文件并转为base64 - :param file_id: Telegram文件ID - :param source: 来源名称 - :return: base64编码的图片数据 - """ - config = self.get_config(source) - if not config: - return None - client = self.get_instance(config.name) - if not client: - return None - file_content = client.download_file(file_id) - if file_content: - import base64 - - return base64.b64encode(file_content).decode() - return None - - def download_telegram_file_bytes(self, file_id: str, source: str) -> Optional[bytes]: - """ - 下载Telegram文件并返回原始字节。 - """ - config = self.get_config(source) - if not config: - return None - client = self.get_instance(config.name) - if not client: - return None - return client.download_file(file_id) +__all__ = ["Telegram", "TelegramModule"] diff --git a/app/modules/telegram/module.py b/app/modules/telegram/module.py new file mode 100644 index 000000000..9901793c5 --- /dev/null +++ b/app/modules/telegram/module.py @@ -0,0 +1,784 @@ +import json +import re +from typing import Optional, Union, List, Tuple, Any + +from app.domain.context import MediaInfo, Context +from app.application.messaging.agent import ( + matches_channel_admin, + register_channel_admin_resolver, + resolve_config_principal_ids, +) +from app.runtime.log import logger +from app.modules._base import _MessageChannelModuleBase +from app.modules.telegram.telegram import Telegram +from app.schemas.notification import NotificationChannel +from app.schemas.message import IncomingMessage +from app.schemas.message import Message +from app.schemas.system import NotificationConf +from app.schemas.message import MessageResponse +from app.schemas.types import ModuleType + + +register_channel_admin_resolver( + NotificationChannel.Telegram, + lambda config: resolve_config_principal_ids( + config, "TELEGRAM_ADMINS", "TELEGRAM_CHAT_ID" + ), +) + + +class TelegramModule(_MessageChannelModuleBase[Telegram]): + """ + Telegram 通知模块,负责模块生命周期、消息解析和通知发送。 + """ + + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "TELEGRAM_ADMINS" + + def init_module(self) -> None: + """ + 初始化模块 + """ + super().init_service( + service_name=Telegram.__name__.lower(), service_type=Telegram + ) + self._channel = NotificationChannel.Telegram + + @staticmethod + def get_name() -> str: + """ + 获取模块名称 + """ + return "Telegram" + + @staticmethod + def get_type() -> ModuleType: + """ + 获取模块类型 + """ + return ModuleType.Notification + + @staticmethod + def get_subtype() -> NotificationChannel: + """ + 获取模块子类型 + """ + return NotificationChannel.Telegram + + @staticmethod + def get_priority() -> int: + """ + 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 + """ + return 0 + + def stop(self) -> None: + """停止模块""" + for client in self.get_instances().values(): + try: + client.stop() + except Exception as err: + logger.error(f"停止Telegram模块实例失败:{err}") + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + """ + 获取模块初始化配置项。 + """ + pass + + def message_parser( + self, source: str, body: Any, form: Any, args: Any + ) -> Optional[IncomingMessage]: + """ + 解析消息内容,返回字典,注意以下约定值: + userid: 用户ID + username: 用户名 + text: 内容 + :param source: 消息来源 + :param body: 请求体 + :param form: 表单 + :param args: 参数 + :return: 渠道、消息体 + """ + """ + 普通消息格式: + { + 'update_id': , + 'message': { + 'message_id': , + 'from': { + 'id': , + 'is_bot': False, + 'first_name': '', + 'username': '', + 'language_code': 'zh-hans' + }, + 'chat': { + 'id': , + 'first_name': '', + 'username': '', + 'type': 'private' + }, + 'date': , + 'text': '' + } + } + + 按钮回调格式: + { + 'callback_query': { + 'id': '', + 'from': {...}, + 'message': {...}, + 'data': 'callback_data' + } + } + """ + # 获取服务配置 + client_config = self.get_config(source) + if not client_config: + return None + client: Telegram = self.get_instance(client_config.name) + try: + message = json.loads(body) + while isinstance(message, str): + message = json.loads(message) + except Exception as err: + logger.debug(f"解析Telegram消息失败:{str(err)}") + return None + + if not isinstance(message, dict): + logger.debug(f"Telegram消息格式无效:{type(message)}") + return None + + # 兼容某些转发链路使用 Telegram Update 外壳 + if "message" in message and isinstance(message.get("message"), dict): + message = message.get("message") + + if message: + # 处理按钮回调 + if "callback_query" in message: + return self._handle_callback_query(message, client_config, client) + + # 处理普通消息 + return self._handle_text_message(message, client_config, client) + + return None + + def _handle_callback_query( + self, message: dict, client_config: NotificationConf, client: Telegram + ) -> Optional[IncomingMessage]: + """ + 处理按钮回调查询 + """ + callback_query = message.get("callback_query", {}) + user_info = callback_query.get("from", {}) + callback_data = callback_query.get("data", "") + user_id = user_info.get("id") + user_name = user_info.get("username") + + if callback_data and user_id: + if str(callback_data).strip().startswith("/") and self._should_reject_admin_command( + client_config.config, user_id + ): + if client: + client.answer_callback_query( + callback_query_id=callback_query.get("id"), + text="只有管理员才有权限执行此命令", + show_alert=True, + ) + return None + + logger.info( + f"收到来自 {client_config.name} 的Telegram按钮回调:" + f"userid={user_id}, username={user_name}, callback_data={callback_data}" + ) + + # 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调 + callback_text = f"CALLBACK:{callback_data}" + + # 创建包含完整回调信息的CommingMessage + return IncomingMessage( + channel=NotificationChannel.Telegram, + source=client_config.name, + userid=user_id, + username=user_name, + is_channel_admin=matches_channel_admin( + NotificationChannel.Telegram, + client_config.config, + user_id, + ), + text=callback_text, + is_callback=True, + callback_data=callback_data, + message_id=callback_query.get("message", {}).get("message_id"), + chat_id=str( + callback_query.get("message", {}).get("chat", {}).get("id", "") + ), + callback_query=callback_query, + ) + return None + + def _handle_text_message( + self, msg: dict, client_config: NotificationConf, client: Telegram + ) -> Optional[IncomingMessage]: + """ + 处理普通文本消息 + """ + text = msg.get("text") or msg.get("caption") + message_id = msg.get("message_id") + user_id = msg.get("from", {}).get("id") + user_name = msg.get("from", {}).get("username") + chat_id = msg.get("chat", {}).get("id") + reply_to_message_id = (msg.get("reply_to_message") or {}).get("message_id") + + # 将 text_link 实体中的 URL 嵌入到文本中 + if text: + text = self._embed_entity_links(text, msg.get("entities") or msg.get("caption_entities")) + + # 将 reply_markup 中的 URL 按钮信息追加到文本中 + text = self._append_reply_markup_links(text, msg.get("reply_markup")) + + images = self._extract_images(msg) + audio_refs = self._extract_audio_refs(msg) + files = self._extract_files(msg) + + if user_id: + if not text and not images and not audio_refs and not files: + logger.debug( + f"收到来自 {client_config.name} 的Telegram消息无文本、图片、语音和文件" + ) + return None + + logger.info( + f"收到来自 {client_config.name} 的Telegram消息:" + f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}, " + f"images={len(images) if images else 0}, audios={len(audio_refs) if audio_refs else 0}, " + f"files={len(files) if files else 0}" + ) + + cleaned_text = ( + self._clean_bot_mention(text, client.bot_username if client else None) + if text + else None + ) + + user_list = client_config.config.get("TELEGRAM_USERS") + + if cleaned_text and cleaned_text.startswith("/"): + if self._should_reject_admin_command(client_config.config, user_id): + client.send_msg( + title="只有管理员才有权限执行此命令", userid=user_id + ) + return None + else: + if user_list and str(user_id) not in user_list.split(","): + logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人") + client.send_msg( + title="你不在用户白名单中,无法使用此机器人", userid=user_id + ) + return None + + return IncomingMessage( + channel=NotificationChannel.Telegram, + source=client_config.name, + userid=user_id, + username=user_name, + is_channel_admin=matches_channel_admin( + NotificationChannel.Telegram, + client_config.config, + user_id, + ), + text=cleaned_text, + message_id=message_id, + chat_id=str(chat_id) if chat_id else None, + reply_to_message_id=reply_to_message_id, + images=images if images else None, + audio_refs=audio_refs if audio_refs else None, + files=files if files else None, + ) + return None + + @staticmethod + def _extract_images(msg: dict) -> Optional[List[IncomingMessage.MessageImage]]: + """ + 从Telegram消息中提取图片file_id + """ + images = [] + photo = msg.get("photo") + if photo and isinstance(photo, list): + largest_photo = photo[-1] + file_id = largest_photo.get("file_id") + if file_id: + images.append( + IncomingMessage.MessageImage( + ref=f"tg://file_id/{file_id}", + mime_type="image/jpeg", + size=largest_photo.get("file_size"), + ) + ) + + document = msg.get("document") + if document: + file_id = document.get("file_id") + mime_type = document.get("mime_type", "") + if file_id and mime_type.startswith("image/"): + images.append( + IncomingMessage.MessageImage( + ref=f"tg://file_id/{file_id}", + name=document.get("file_name"), + mime_type=document.get("mime_type"), + size=document.get("file_size"), + ) + ) + + return images if images else None + + @staticmethod + def _extract_audio_refs(msg: dict) -> Optional[List[str]]: + """ + 从Telegram消息中提取语音/音频 file_id。 + """ + audio_refs = [] + voice = msg.get("voice") + if voice: + file_id = voice.get("file_id") + if file_id: + audio_refs.append(f"tg://voice_file_id/{file_id}") + + audio = msg.get("audio") + if audio: + file_id = audio.get("file_id") + if file_id: + audio_refs.append(f"tg://audio_file_id/{file_id}") + + return audio_refs if audio_refs else None + + @staticmethod + def _extract_files(msg: dict) -> Optional[List[IncomingMessage.MessageAttachment]]: + """ + 从 Telegram 消息中提取非图片文件附件。 + """ + document = msg.get("document") + if not isinstance(document, dict): + return None + + file_id = document.get("file_id") + mime_type = (document.get("mime_type") or "").lower() + if not file_id or mime_type.startswith("image/"): + return None + + return [ + IncomingMessage.MessageAttachment( + ref=f"tg://document_file_id/{file_id}", + name=document.get("file_name"), + mime_type=document.get("mime_type"), + size=document.get("file_size"), + ) + ] + + @staticmethod + def _embed_entity_links(text: str, entities: Optional[List[dict]]) -> str: + """ + 将 text_link 实体中的 URL 嵌入到文本中 + + :param text: 原始文本 + :param entities: 消息实体列表 + :return: 嵌入链接后的文本 + """ + if not entities: + return text + text_link_entities = sorted( + [e for e in entities if e.get("type") == "text_link" and e.get("url")], + key=lambda e: e.get("offset", 0), + reverse=True, + ) + text_utf16 = text.encode("utf-16-le") + for entity in text_link_entities: + offset = entity.get("offset", 0) + length = entity.get("length", 0) + url = entity["url"] + char_offset = len(text_utf16[:offset * 2].decode("utf-16-le")) + char_length = len(text_utf16[offset * 2: (offset + length) * 2].decode("utf-16-le")) + display_text = text[char_offset: char_offset + char_length] + text = text[:char_offset] + f"{display_text}({url})" + text[char_offset + char_length:] + text_utf16 = text.encode("utf-16-le") + return text + + @staticmethod + def _append_reply_markup_links(text: Optional[str], reply_markup: Optional[dict]) -> Optional[str]: + """ + 将 reply_markup 中的 URL 按钮信息追加到文本末尾 + + :param text: 原始文本 + :param reply_markup: 消息的 reply_markup 字段 + :return: 追加按钮链接后的文本 + """ + if not reply_markup: + return text + inline_keyboard = reply_markup.get("inline_keyboard") + if not inline_keyboard: + return text + button_lines = [] + for row in inline_keyboard: + for button in row: + btn_text = button.get("text", "") + btn_url = button.get("url") + if btn_url: + button_lines.append(f"{btn_text}({btn_url})") + if not button_lines: + return text + buttons_text = "\n".join(button_lines) + if text: + return f"{text}\n{buttons_text}" + return buttons_text + + @staticmethod + def _clean_bot_mention(text: str, bot_username: Optional[str]) -> str: + """ + 清理消息中的@bot部分,确保文本处理一致性 + :param text: 原始消息文本 + :param bot_username: bot用户名 + :return: 清理后的文本 + """ + if not text or not bot_username: + return text + + # Remove @bot_username from the beginning and any position in text + cleaned = text + mention_pattern = f"@{bot_username}" + + # Remove mention at the beginning with optional following space + if cleaned.startswith(mention_pattern): + cleaned = cleaned[len(mention_pattern):].lstrip() + + # Remove mention at any other position + cleaned = cleaned.replace(mention_pattern, "").strip() + + # Clean up multiple spaces + cleaned = re.sub(r"\s+", " ", cleaned).strip() + + return cleaned + + def post_message(self, message: Message, **kwargs) -> None: + """ + 发送消息 + :param message: 消息体 + :return: 成功或失败 + """ + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + targets = message.targets + userid = message.userid + if not userid and targets is not None: + userid = targets.get("telegram_userid") + if not userid: + logger.warn(f"用户没有指定 Telegram用户ID,消息无法发送") + return + client: Telegram = self.get_instance(conf.name) + if client: + if message.file_path: + client.send_file( + file_path=message.file_path, + file_name=message.file_name, + title=message.title, + text=message.text, + userid=userid, + original_chat_id=message.original_chat_id, + parse_mode=message.parse_mode, + ) + elif message.voice_path: + client.send_voice( + voice_path=message.voice_path, + userid=userid, + caption=message.voice_caption, + original_chat_id=message.original_chat_id, + parse_mode=message.parse_mode, + ) + else: + # Telegram 的 reply_markup 不能同时承载 InlineKeyboard 和 ForceReply。 + # 普通通知只清空可编辑消息 ID,仍保留原会话作为新消息目标。 + has_interaction_context = bool(message.buttons or message.force_reply) + original_message_id = ( + message.original_message_id if has_interaction_context else None + ) + client.send_msg( + title=message.title, + text=message.text, + image=message.image, + userid=userid, + link=message.link, + buttons=message.buttons, + force_reply=message.force_reply, + original_message_id=original_message_id, + original_chat_id=message.original_chat_id, + disable_web_page_preview=message.disable_web_page_preview, + parse_mode=message.parse_mode, + ) + + def post_medias_message( + self, message: Message, medias: List[MediaInfo] + ) -> None: + """ + 发送媒体信息选择列表 + :param message: 消息体 + :param medias: 媒体列表 + :return: 成功或失败 + """ + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + client: Telegram = self.get_instance(conf.name) + if client: + client.send_medias_msg( + title=message.title, + medias=medias, + userid=message.userid, + link=message.link, + buttons=message.buttons, + original_message_id=message.original_message_id, + original_chat_id=message.original_chat_id, + parse_mode=message.parse_mode, + ) + + def post_torrents_message( + self, message: Message, torrents: List[Context] + ) -> None: + """ + 发送种子信息选择列表 + :param message: 消息体 + :param torrents: 种子列表 + :return: 成功或失败 + """ + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + client: Telegram = self.get_instance(conf.name) + if client: + client.send_torrents_msg( + title=message.title, + torrents=torrents, + userid=message.userid, + link=message.link, + buttons=message.buttons, + original_message_id=message.original_message_id, + original_chat_id=message.original_chat_id, + parse_mode=message.parse_mode, + ) + + def delete_message( + self, + channel: NotificationChannel, + source: str, + message_id: int, + chat_id: Optional[int] = None, + ) -> Optional[bool]: + """ + 删除消息 + :param channel: 消息渠道 + :param source: 指定的消息源 + :param message_id: 消息ID + :param chat_id: 聊天ID + :return: 删除是否成功 + """ + if channel != self._channel: + return None + success = False + for conf in self.get_configs().values(): + if source != conf.name: + continue + client: Telegram = self.get_instance(conf.name) + if client: + result = client.delete_msg(message_id=message_id, chat_id=chat_id) + if result: + success = True + return success + + def edit_message( + self, + channel: NotificationChannel, + source: str, + message_id: Union[str, int], + chat_id: Union[str, int], + text: str, + title: Optional[str] = None, + buttons: Optional[List[List[dict]]] = None, + metadata: Optional[dict] = None, + parse_mode: Optional[str] = None, + ) -> Optional[bool]: + """ + 编辑消息 + :param channel: 消息渠道 + :param source: 指定的消息源 + :param message_id: 消息ID + :param chat_id: 聊天ID + :param text: 新的消息内容 + :param title: 消息标题 + :param buttons: 新的按钮列表 + :param metadata: 其他元信息 + :param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML + :return: 编辑是否成功 + """ + if channel != self._channel: + return None + for conf in self.get_configs().values(): + if source != conf.name: + continue + client: Telegram = self.get_instance(conf.name) + if client: + result = client.edit_msg( + chat_id=chat_id, + message_id=message_id, + text=text, + title=title, + buttons=buttons, + parse_mode=parse_mode, + ) + if result: + return True + return False + + def mark_message_processing_started( + self, + channel: NotificationChannel, + source: str, + userid: Optional[Union[str, int]] = None, + message_id: Optional[Union[str, int]] = None, + chat_id: Optional[Union[str, int]] = None, + text: Optional[str] = None, + ) -> Optional[dict]: + """ + 标记 Telegram 消息正在处理。 + Telegram typing 需要周期性续发,因此在模块接口中启动保活任务。 + """ + if channel != self._channel: + return None + client_config = self.get_config(source) + if not client_config: + return None + client: Telegram = self.get_instance(client_config.name) + if not client: + return None + started = client.start_typing(chat_id=chat_id, userid=userid) + if not started: + return None + return { + "channel": channel.value, + "source": source, + "userid": userid, + "message_id": message_id, + "chat_id": chat_id, + "metadata": {"kind": "typing"}, + } + + def mark_message_processing_finished( + self, + channel: NotificationChannel, + source: str, + userid: Optional[Union[str, int]] = None, + message_id: Optional[Union[str, int]] = None, + chat_id: Optional[Union[str, int]] = None, + status: Optional[dict] = None, + ) -> Optional[bool]: + """ + 结束 Telegram typing 状态。 + """ + if channel != self._channel: + return None + if status: + chat_id = status.get("chat_id") or chat_id + userid = status.get("userid") or userid + client_config = self.get_config(source) + if not client_config: + return False + client: Telegram = self.get_instance(client_config.name) + if not client: + return False + return client.stop_typing(chat_id=chat_id, userid=userid) + + def send_direct_message(self, message: Message) -> Optional[MessageResponse]: + """ + 直接发送消息并返回消息ID等信息 + :param message: 消息体 + :return: 消息响应(包含message_id, chat_id等) + """ + for conf in self.get_configs().values(): + if not self.check_message(message, conf.name): + continue + targets = message.targets + userid = message.userid + if not userid and targets is not None: + userid = targets.get("telegram_userid") + if not userid: + logger.warn("用户没有指定 Telegram用户ID,消息无法发送") + return None + client: Telegram = self.get_instance(conf.name) + if client: + if message.voice_path: + result = client.send_voice( + voice_path=message.voice_path, + userid=userid, + caption=message.voice_caption, + original_chat_id=message.original_chat_id, + parse_mode=message.parse_mode, + ) + else: + # direct message 只禁用编辑旧消息;仅 ForceReply 使用 original_chat_id + # 发回原会话,并保留 original_message_id 让 client reply_to 原消息。 + original_chat_id = message.original_chat_id if message.force_reply else None + original_message_id = message.original_message_id if message.force_reply else None + result = client.send_msg( + title=message.title, + text=message.text, + image=message.image, + userid=userid, + link=message.link, + force_reply=message.force_reply, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + disable_web_page_preview=message.disable_web_page_preview, + parse_mode=message.parse_mode, + private_delivery=message.private_delivery, + ) + if result and result.get("success"): + return MessageResponse( + message_id=result.get("message_id"), + chat_id=result.get("chat_id"), + channel=NotificationChannel.Telegram, + source=conf.name, + success=True, + ) + return None + + def download_telegram_file_to_base64(self, file_id: str, source: str) -> Optional[str]: + """ + 下载Telegram文件并转为base64 + :param file_id: Telegram文件ID + :param source: 来源名称 + :return: base64编码的图片数据 + """ + config = self.get_config(source) + if not config: + return None + client = self.get_instance(config.name) + if not client: + return None + file_content = client.download_file(file_id) + if file_content: + import base64 + + return base64.b64encode(file_content).decode() + return None + + def download_telegram_file_bytes(self, file_id: str, source: str) -> Optional[bytes]: + """ + 下载Telegram文件并返回原始字节。 + """ + config = self.get_config(source) + if not config: + return None + client = self.get_instance(config.name) + if not client: + return None + return client.download_file(file_id) diff --git a/app/modules/trimemedia/__init__.py b/app/modules/trimemedia/__init__.py index 396341f78..5d8116413 100644 --- a/app/modules/trimemedia/__init__.py +++ b/app/modules/trimemedia/__init__.py @@ -1,288 +1,31 @@ -from typing import Any, Generator, List, Optional, Tuple, Union +"""飞牛影视宿主模块的惰性兼容入口。""" -from app.schemas.dashboard import Statistic as _SchemaStatistic -from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem -from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary -from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem -from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerSeasonInfo -from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo -from app.runtime.log import logger -from app.modules._base import _MediaServerModuleBase -from app.modules.trimemedia.trimemedia import TrimeMedia -from app.schemas.types import MediaServerType, ModuleType +from importlib import import_module +from typing import Any -class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): +_EXPORTS = { + "TrimeMedia": ("app.modules.trimemedia.trimemedia", "TrimeMedia"), + "TrimeMediaModule": ("app.modules.trimemedia.module", "TrimeMediaModule"), +} - # 媒体库标识(ExistMediaInfo.server_type) - _server_type_value = "trimemedia" - def init_module(self) -> None: - """ - 初始化模块 - """ - super().init_service( - service_name=TrimeMedia.__name__.lower(), - service_type=lambda conf: TrimeMedia( - **conf.config, sync_libraries=conf.sync_libraries - ), - ) +def __getattr__(name: str) -> Any: + """按需解析历史包级导出,并保持模块类的原始反射路径。""" + contract = _EXPORTS.get(name) + if contract is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, symbol_name = contract + value = getattr(import_module(module_name), symbol_name) + if name == "TrimeMediaModule": + value.__module__ = __name__ + globals()[name] = value + return value - @staticmethod - def get_name() -> str: - return "飞牛影视" - @staticmethod - def get_type() -> ModuleType: - """ - 获取模块类型 - """ - return ModuleType.MediaServer +def __dir__() -> list[str]: + """向交互式工具公开兼容符号而不提前加载实现。""" + return sorted({*globals(), *_EXPORTS}) - @staticmethod - def get_subtype() -> MediaServerType: - """ - 获取模块子类型 - """ - return MediaServerType.TrimeMedia - @staticmethod - def get_priority() -> int: - """ - 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 - """ - return 4 - - def init_setting(self) -> Tuple[str, Union[str, bool]]: - pass - - def _is_inactive(self, server) -> bool: - """未配置的实例不参与定时重连。""" - return server.is_configured() and server.is_inactive() - - def stop(self) -> None: - """停止模块""" - for server in self.get_instances().values(): - try: - if server.is_authenticated(): - server.disconnect() - except Exception as err: - logger.error(f"停止飞牛影视模块实例失败:{err}") - - def _test_server(self, server, name: str) -> Optional[str]: - """飞牛影视用配置完整性与重连结果探测连接状态。""" - if not server.is_configured(): - return f"{self.get_name()}配置不完整:{name}" - if server.is_inactive() and not server.reconnect(): - return f"无法连接{self.get_name()}:{name}" - return None - - def webhook_parser( - self, body: Any, form: Any, args: Any - ) -> Optional[_SchemaWebhookEventInfo]: - """ - 解析Webhook报文体 - - :param body: 请求体 - :param form: 请求表单 - :param args: 请求参数 - :return: 字典,解析为消息时需要包含:title、text、image - """ - source = args.get("source") - if source: - server: Optional[TrimeMedia] = self.get_instance(source) - if not server: - return None - result = server.get_webhook_message(body) - if result: - result.server_name = source - return result - - for server in self.get_instances().values(): - if server: - result = server.get_webhook_message(body) - if result: - return result - return None - - def media_statistic( - self, server: Optional[str] = None - ) -> Optional[List[_SchemaStatistic]]: - """ - 媒体数量统计 - """ - if server: - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if not server_obj: - return None - servers = [server_obj] - else: - servers = self.get_instances().values() - media_statistics = [] - for s in servers: - media_statistic = s.get_medias_count() - if not media_statistic: - continue - media_statistic.user_count = s.get_user_count() - media_statistics.append(media_statistic) - return media_statistics - - def mediaserver_librarys( - self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs - ) -> Optional[List[_SchemaMediaServerLibrary]]: - """ - 媒体库列表 - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if server_obj: - return server_obj.get_librarys(hidden=hidden) - return None - - def mediaserver_items( - self, - server: str, - library_id: Union[str, int], - start_index: Optional[int] = 0, - limit: Optional[int] = -1, - ) -> Optional[Generator]: - """ - 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 - - :param server: 媒体服务器名称 - :param library_id: 媒体库ID,用于标识要获取的媒体库 - :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 - :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 - - :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if server_obj: - return server_obj.get_items(library_id, start_index, limit) - return None - - def mediaserver_items_count( - self, server: str, library_id: Union[str, int] - ) -> Optional[int]: - """ - 获取指定媒体库可同步的媒体条目总数 - - :param server: 媒体服务器名称 - :param library_id: 媒体库ID - :return: 媒体条目总数,查询失败时返回None - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if server_obj: - return server_obj.get_items_count(library_id) - return None - - def mediaserver_iteminfo( - self, server: str, item_id: str - ) -> Optional[_SchemaMediaServerItem]: - """ - 媒体库项目详情 - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if server_obj: - return server_obj.get_iteminfo(item_id) - return None - - def mediaserver_tv_episodes( - self, server: str, item_id: Union[str, int] - ) -> Optional[List[_SchemaMediaServerSeasonInfo]]: - """ - 获取剧集信息 - """ - if not isinstance(item_id, str): - return None - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if not server_obj: - return None - _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) - if not seasoninfo: - return [] - return [ - _SchemaMediaServerSeasonInfo(season=season, episodes=episodes) - for season, episodes in seasoninfo.items() - ] - - def mediaserver_playing( - self, server: str, count: Optional[int] = 20, **kwargs - ) -> Optional[List[_SchemaMediaServerPlayItem]]: - """ - 获取媒体服务器正在播放信息 - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_resume(num=count) - - def mediaserver_play_url( - self, server: str, item_id: Union[str, int] - ) -> Optional[str]: - """ - 获取媒体库播放地址 - """ - if not isinstance(item_id, str): - return None - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_play_url(item_id) - - def mediaserver_latest( - self, - server: Optional[str] = None, - count: Optional[int] = 20, - **kwargs, - ) -> Optional[List[_SchemaMediaServerPlayItem]]: - """ - 获取媒体服务器最新入库条目 - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_latest(num=count) - - def mediaserver_latest_images( - self, - server: Optional[str] = None, - count: Optional[int] = 20, - remote: Optional[bool] = False, - **kwargs, - ) -> List[str]: - """ - 获取媒体服务器最新入库条目的图片 - - :param server: 媒体服务器名称 - :param count: 获取数量 - :param remote: True为外网链接, False为内网链接 - :return: 图片链接列表 - """ - server_obj: Optional[TrimeMedia] = self.get_instance(server) - if not server_obj: - return [] - return server_obj.get_latest_backdrops(num=count, remote=remote) or [] - - def mediaserver_image_cookies( - self, - server: Optional[str] = None, - image_url: Optional[str] = None, - **kwargs, - ) -> Optional[str | dict]: - """ - 获取飞牛影视服务器的图片Cookies - - :param server: 媒体服务器名称 - :param image_url: 图片网址 - """ - if not image_url: - return None - if server: - server_obj = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_image_cookies(image_url) - else: - for server_obj in self.get_instances().values(): - if cookies := server_obj.get_image_cookies(image_url): - return cookies +__all__ = ["TrimeMedia", "TrimeMediaModule"] diff --git a/app/modules/trimemedia/module.py b/app/modules/trimemedia/module.py new file mode 100644 index 000000000..396341f78 --- /dev/null +++ b/app/modules/trimemedia/module.py @@ -0,0 +1,288 @@ +from typing import Any, Generator, List, Optional, Tuple, Union + +from app.schemas.dashboard import Statistic as _SchemaStatistic +from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem +from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary +from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem +from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerSeasonInfo +from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo +from app.runtime.log import logger +from app.modules._base import _MediaServerModuleBase +from app.modules.trimemedia.trimemedia import TrimeMedia +from app.schemas.types import MediaServerType, ModuleType + + +class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "trimemedia" + + def init_module(self) -> None: + """ + 初始化模块 + """ + super().init_service( + service_name=TrimeMedia.__name__.lower(), + service_type=lambda conf: TrimeMedia( + **conf.config, sync_libraries=conf.sync_libraries + ), + ) + + @staticmethod + def get_name() -> str: + return "飞牛影视" + + @staticmethod + def get_type() -> ModuleType: + """ + 获取模块类型 + """ + return ModuleType.MediaServer + + @staticmethod + def get_subtype() -> MediaServerType: + """ + 获取模块子类型 + """ + return MediaServerType.TrimeMedia + + @staticmethod + def get_priority() -> int: + """ + 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 + """ + return 4 + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + pass + + def _is_inactive(self, server) -> bool: + """未配置的实例不参与定时重连。""" + return server.is_configured() and server.is_inactive() + + def stop(self) -> None: + """停止模块""" + for server in self.get_instances().values(): + try: + if server.is_authenticated(): + server.disconnect() + except Exception as err: + logger.error(f"停止飞牛影视模块实例失败:{err}") + + def _test_server(self, server, name: str) -> Optional[str]: + """飞牛影视用配置完整性与重连结果探测连接状态。""" + if not server.is_configured(): + return f"{self.get_name()}配置不完整:{name}" + if server.is_inactive() and not server.reconnect(): + return f"无法连接{self.get_name()}:{name}" + return None + + def webhook_parser( + self, body: Any, form: Any, args: Any + ) -> Optional[_SchemaWebhookEventInfo]: + """ + 解析Webhook报文体 + + :param body: 请求体 + :param form: 请求表单 + :param args: 请求参数 + :return: 字典,解析为消息时需要包含:title、text、image + """ + source = args.get("source") + if source: + server: Optional[TrimeMedia] = self.get_instance(source) + if not server: + return None + result = server.get_webhook_message(body) + if result: + result.server_name = source + return result + + for server in self.get_instances().values(): + if server: + result = server.get_webhook_message(body) + if result: + return result + return None + + def media_statistic( + self, server: Optional[str] = None + ) -> Optional[List[_SchemaStatistic]]: + """ + 媒体数量统计 + """ + if server: + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if not server_obj: + return None + servers = [server_obj] + else: + servers = self.get_instances().values() + media_statistics = [] + for s in servers: + media_statistic = s.get_medias_count() + if not media_statistic: + continue + media_statistic.user_count = s.get_user_count() + media_statistics.append(media_statistic) + return media_statistics + + def mediaserver_librarys( + self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs + ) -> Optional[List[_SchemaMediaServerLibrary]]: + """ + 媒体库列表 + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if server_obj: + return server_obj.get_librarys(hidden=hidden) + return None + + def mediaserver_items( + self, + server: str, + library_id: Union[str, int], + start_index: Optional[int] = 0, + limit: Optional[int] = -1, + ) -> Optional[Generator]: + """ + 获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据 + + :param server: 媒体服务器名称 + :param library_id: 媒体库ID,用于标识要获取的媒体库 + :param start_index: 起始索引,用于分页获取数据。默认为 0,即从第一个项目开始获取 + :param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1,则表示一次性获取所有数据,默认为 -1 + + :return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目 + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if server_obj: + return server_obj.get_items(library_id, start_index, limit) + return None + + def mediaserver_items_count( + self, server: str, library_id: Union[str, int] + ) -> Optional[int]: + """ + 获取指定媒体库可同步的媒体条目总数 + + :param server: 媒体服务器名称 + :param library_id: 媒体库ID + :return: 媒体条目总数,查询失败时返回None + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if server_obj: + return server_obj.get_items_count(library_id) + return None + + def mediaserver_iteminfo( + self, server: str, item_id: str + ) -> Optional[_SchemaMediaServerItem]: + """ + 媒体库项目详情 + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if server_obj: + return server_obj.get_iteminfo(item_id) + return None + + def mediaserver_tv_episodes( + self, server: str, item_id: Union[str, int] + ) -> Optional[List[_SchemaMediaServerSeasonInfo]]: + """ + 获取剧集信息 + """ + if not isinstance(item_id, str): + return None + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if not server_obj: + return None + _, seasoninfo = server_obj.get_tv_episodes(item_id=item_id) + if not seasoninfo: + return [] + return [ + _SchemaMediaServerSeasonInfo(season=season, episodes=episodes) + for season, episodes in seasoninfo.items() + ] + + def mediaserver_playing( + self, server: str, count: Optional[int] = 20, **kwargs + ) -> Optional[List[_SchemaMediaServerPlayItem]]: + """ + 获取媒体服务器正在播放信息 + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_resume(num=count) + + def mediaserver_play_url( + self, server: str, item_id: Union[str, int] + ) -> Optional[str]: + """ + 获取媒体库播放地址 + """ + if not isinstance(item_id, str): + return None + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_play_url(item_id) + + def mediaserver_latest( + self, + server: Optional[str] = None, + count: Optional[int] = 20, + **kwargs, + ) -> Optional[List[_SchemaMediaServerPlayItem]]: + """ + 获取媒体服务器最新入库条目 + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_latest(num=count) + + def mediaserver_latest_images( + self, + server: Optional[str] = None, + count: Optional[int] = 20, + remote: Optional[bool] = False, + **kwargs, + ) -> List[str]: + """ + 获取媒体服务器最新入库条目的图片 + + :param server: 媒体服务器名称 + :param count: 获取数量 + :param remote: True为外网链接, False为内网链接 + :return: 图片链接列表 + """ + server_obj: Optional[TrimeMedia] = self.get_instance(server) + if not server_obj: + return [] + return server_obj.get_latest_backdrops(num=count, remote=remote) or [] + + def mediaserver_image_cookies( + self, + server: Optional[str] = None, + image_url: Optional[str] = None, + **kwargs, + ) -> Optional[str | dict]: + """ + 获取飞牛影视服务器的图片Cookies + + :param server: 媒体服务器名称 + :param image_url: 图片网址 + """ + if not image_url: + return None + if server: + server_obj = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_image_cookies(image_url) + else: + for server_obj in self.get_instances().values(): + if cookies := server_obj.get_image_cookies(image_url): + return cookies diff --git a/app/modules/ugreen/__init__.py b/app/modules/ugreen/__init__.py index dda0a8d67..2e5241251 100644 --- a/app/modules/ugreen/__init__.py +++ b/app/modules/ugreen/__init__.py @@ -1,269 +1,31 @@ -from typing import Any, Generator, List, Optional, Tuple, Union +"""绿联影视宿主模块的惰性兼容入口。""" -from app.schemas.dashboard import Statistic as _SchemaStatistic -from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem -from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary -from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem -from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerSeasonInfo -from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo -from app.runtime.log import logger -from app.modules._base import _MediaServerModuleBase -from app.modules.ugreen.ugreen import Ugreen -from app.schemas.types import MediaServerType, ModuleType +from importlib import import_module +from typing import Any -class UgreenModule(_MediaServerModuleBase[Ugreen]): +_EXPORTS = { + "Ugreen": ("app.modules.ugreen.ugreen", "Ugreen"), + "UgreenModule": ("app.modules.ugreen.module", "UgreenModule"), +} - # 媒体库标识(ExistMediaInfo.server_type) - _server_type_value = "ugreen" - def init_module(self) -> None: - """ - 初始化模块 - """ - super().init_service( - service_name=Ugreen.__name__.lower(), - service_type=lambda conf: Ugreen( - **conf.config, sync_libraries=conf.sync_libraries - ), - ) +def __getattr__(name: str) -> Any: + """按需解析历史包级导出,并保持模块类的原始反射路径。""" + contract = _EXPORTS.get(name) + if contract is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, symbol_name = contract + value = getattr(import_module(module_name), symbol_name) + if name == "UgreenModule": + value.__module__ = __name__ + globals()[name] = value + return value - @staticmethod - def get_name() -> str: - return "绿联影视" - @staticmethod - def get_type() -> ModuleType: - """ - 获取模块类型 - """ - return ModuleType.MediaServer +def __dir__() -> list[str]: + """向交互式工具公开兼容符号而不提前加载实现。""" + return sorted({*globals(), *_EXPORTS}) - @staticmethod - def get_subtype() -> MediaServerType: - """ - 获取模块子类型 - """ - return MediaServerType.Ugreen - @staticmethod - def get_priority() -> int: - """ - 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 - """ - return 5 - - def init_setting(self) -> Tuple[str, Union[str, bool]]: - pass - - def _is_inactive(self, server) -> bool: - """未配置的实例不参与定时重连。""" - return server.is_configured() and server.is_inactive() - - def stop(self) -> None: - """停止模块""" - for server in self.get_instances().values(): - try: - if server.is_authenticated(): - server.disconnect() - except Exception as err: - logger.error(f"停止绿联影视模块实例失败:{err}") - - def _test_server(self, server, name: str) -> Optional[str]: - """绿联影视用配置完整性与重连结果探测连接状态。""" - if not server.is_configured(): - return f"{self.get_name()}配置不完整:{name}" - if server.is_inactive() and not server.reconnect(): - return f"无法连接{self.get_name()}:{name}" - return None - - def webhook_parser( - self, body: Any, form: Any, args: Any - ) -> Optional[_SchemaWebhookEventInfo]: - """ - 解析Webhook报文体 - """ - source = args.get("source") - if source: - server: Optional[Ugreen] = self.get_instance(source) - if not server: - return None - result = server.get_webhook_message(body) - if result: - result.server_name = source - return result - - for server in self.get_instances().values(): - if server: - result = server.get_webhook_message(body) - if result: - return result - return None - - def media_statistic( - self, server: Optional[str] = None - ) -> Optional[List[_SchemaStatistic]]: - """ - 媒体数量统计 - """ - if server: - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return None - servers = [server_obj] - else: - servers = self.get_instances().values() - - media_statistics = [] - for s in servers: - media_statistic = s.get_medias_count() - if not media_statistic: - continue - media_statistic.user_count = s.get_user_count() - media_statistics.append(media_statistic) - return media_statistics - - def mediaserver_librarys( - self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs - ) -> Optional[List[_SchemaMediaServerLibrary]]: - """ - 媒体库列表 - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if server_obj: - return server_obj.get_librarys(hidden=hidden) - return None - - def mediaserver_items( - self, - server: str, - library_id: Union[str, int], - start_index: Optional[int] = 0, - limit: Optional[int] = -1, - ) -> Optional[Generator]: - """ - 获取媒体服务器项目列表 - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if server_obj: - return server_obj.get_items(library_id, start_index, limit) - return None - - def mediaserver_items_count( - self, server: str, library_id: Union[str, int] - ) -> Optional[int]: - """ - 获取指定媒体库可同步的媒体条目总数 - - :param server: 媒体服务器名称 - :param library_id: 媒体库ID - :return: 媒体条目总数,查询失败时返回None - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if server_obj: - return server_obj.get_items_count(library_id) - return None - - def mediaserver_iteminfo( - self, server: str, item_id: str - ) -> Optional[_SchemaMediaServerItem]: - """ - 媒体库项目详情 - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if server_obj: - return server_obj.get_iteminfo(item_id) - return None - - def mediaserver_tv_episodes( - self, server: str, item_id: Union[str, int] - ) -> Optional[List[_SchemaMediaServerSeasonInfo]]: - """ - 获取剧集信息 - """ - if not item_id: - return None - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return None - _, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id)) - if not seasoninfo: - return [] - return [ - _SchemaMediaServerSeasonInfo(season=season, episodes=episodes) - for season, episodes in seasoninfo.items() - ] - - def mediaserver_playing( - self, server: str, count: Optional[int] = 20, **kwargs - ) -> Optional[List[_SchemaMediaServerPlayItem]]: - """ - 获取媒体服务器正在播放信息 - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_resume(num=count) - - def mediaserver_play_url( - self, server: str, item_id: Union[str, int] - ) -> Optional[str]: - """ - 获取媒体库播放地址 - """ - if not item_id: - return None - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_play_url(str(item_id)) - - def mediaserver_latest( - self, - server: Optional[str] = None, - count: Optional[int] = 20, - **kwargs, - ) -> Optional[List[_SchemaMediaServerPlayItem]]: - """ - 获取媒体服务器最新入库条目 - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_latest(num=count) - - def mediaserver_latest_images( - self, - server: Optional[str] = None, - count: Optional[int] = 20, - remote: Optional[bool] = False, - **kwargs, - ) -> List[str]: - """ - 获取媒体服务器最新入库条目的图片 - """ - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return [] - return server_obj.get_latest_backdrops(num=count, remote=remote) or [] - - def mediaserver_image_cookies( - self, - server: Optional[str] = None, - image_url: Optional[str] = None, - **kwargs, - ) -> Optional[str | dict]: - """ - 获取绿联影视服务器的图片Cookies - """ - if not image_url: - return None - if server: - server_obj: Optional[Ugreen] = self.get_instance(server) - if not server_obj: - return None - return server_obj.get_image_cookies(image_url) - for server_obj in self.get_instances().values(): - if cookies := server_obj.get_image_cookies(image_url): - return cookies - return None +__all__ = ["Ugreen", "UgreenModule"] diff --git a/app/modules/ugreen/module.py b/app/modules/ugreen/module.py new file mode 100644 index 000000000..dda0a8d67 --- /dev/null +++ b/app/modules/ugreen/module.py @@ -0,0 +1,269 @@ +from typing import Any, Generator, List, Optional, Tuple, Union + +from app.schemas.dashboard import Statistic as _SchemaStatistic +from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem +from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary +from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem +from app.schemas.mediaserver import MediaServerSeasonInfo as _SchemaMediaServerSeasonInfo +from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo +from app.runtime.log import logger +from app.modules._base import _MediaServerModuleBase +from app.modules.ugreen.ugreen import Ugreen +from app.schemas.types import MediaServerType, ModuleType + + +class UgreenModule(_MediaServerModuleBase[Ugreen]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "ugreen" + + def init_module(self) -> None: + """ + 初始化模块 + """ + super().init_service( + service_name=Ugreen.__name__.lower(), + service_type=lambda conf: Ugreen( + **conf.config, sync_libraries=conf.sync_libraries + ), + ) + + @staticmethod + def get_name() -> str: + return "绿联影视" + + @staticmethod + def get_type() -> ModuleType: + """ + 获取模块类型 + """ + return ModuleType.MediaServer + + @staticmethod + def get_subtype() -> MediaServerType: + """ + 获取模块子类型 + """ + return MediaServerType.Ugreen + + @staticmethod + def get_priority() -> int: + """ + 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 + """ + return 5 + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + pass + + def _is_inactive(self, server) -> bool: + """未配置的实例不参与定时重连。""" + return server.is_configured() and server.is_inactive() + + def stop(self) -> None: + """停止模块""" + for server in self.get_instances().values(): + try: + if server.is_authenticated(): + server.disconnect() + except Exception as err: + logger.error(f"停止绿联影视模块实例失败:{err}") + + def _test_server(self, server, name: str) -> Optional[str]: + """绿联影视用配置完整性与重连结果探测连接状态。""" + if not server.is_configured(): + return f"{self.get_name()}配置不完整:{name}" + if server.is_inactive() and not server.reconnect(): + return f"无法连接{self.get_name()}:{name}" + return None + + def webhook_parser( + self, body: Any, form: Any, args: Any + ) -> Optional[_SchemaWebhookEventInfo]: + """ + 解析Webhook报文体 + """ + source = args.get("source") + if source: + server: Optional[Ugreen] = self.get_instance(source) + if not server: + return None + result = server.get_webhook_message(body) + if result: + result.server_name = source + return result + + for server in self.get_instances().values(): + if server: + result = server.get_webhook_message(body) + if result: + return result + return None + + def media_statistic( + self, server: Optional[str] = None + ) -> Optional[List[_SchemaStatistic]]: + """ + 媒体数量统计 + """ + if server: + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return None + servers = [server_obj] + else: + servers = self.get_instances().values() + + media_statistics = [] + for s in servers: + media_statistic = s.get_medias_count() + if not media_statistic: + continue + media_statistic.user_count = s.get_user_count() + media_statistics.append(media_statistic) + return media_statistics + + def mediaserver_librarys( + self, server: Optional[str] = None, hidden: Optional[bool] = False, **kwargs + ) -> Optional[List[_SchemaMediaServerLibrary]]: + """ + 媒体库列表 + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if server_obj: + return server_obj.get_librarys(hidden=hidden) + return None + + def mediaserver_items( + self, + server: str, + library_id: Union[str, int], + start_index: Optional[int] = 0, + limit: Optional[int] = -1, + ) -> Optional[Generator]: + """ + 获取媒体服务器项目列表 + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if server_obj: + return server_obj.get_items(library_id, start_index, limit) + return None + + def mediaserver_items_count( + self, server: str, library_id: Union[str, int] + ) -> Optional[int]: + """ + 获取指定媒体库可同步的媒体条目总数 + + :param server: 媒体服务器名称 + :param library_id: 媒体库ID + :return: 媒体条目总数,查询失败时返回None + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if server_obj: + return server_obj.get_items_count(library_id) + return None + + def mediaserver_iteminfo( + self, server: str, item_id: str + ) -> Optional[_SchemaMediaServerItem]: + """ + 媒体库项目详情 + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if server_obj: + return server_obj.get_iteminfo(item_id) + return None + + def mediaserver_tv_episodes( + self, server: str, item_id: Union[str, int] + ) -> Optional[List[_SchemaMediaServerSeasonInfo]]: + """ + 获取剧集信息 + """ + if not item_id: + return None + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return None + _, seasoninfo = server_obj.get_tv_episodes(item_id=str(item_id)) + if not seasoninfo: + return [] + return [ + _SchemaMediaServerSeasonInfo(season=season, episodes=episodes) + for season, episodes in seasoninfo.items() + ] + + def mediaserver_playing( + self, server: str, count: Optional[int] = 20, **kwargs + ) -> Optional[List[_SchemaMediaServerPlayItem]]: + """ + 获取媒体服务器正在播放信息 + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_resume(num=count) + + def mediaserver_play_url( + self, server: str, item_id: Union[str, int] + ) -> Optional[str]: + """ + 获取媒体库播放地址 + """ + if not item_id: + return None + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_play_url(str(item_id)) + + def mediaserver_latest( + self, + server: Optional[str] = None, + count: Optional[int] = 20, + **kwargs, + ) -> Optional[List[_SchemaMediaServerPlayItem]]: + """ + 获取媒体服务器最新入库条目 + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_latest(num=count) + + def mediaserver_latest_images( + self, + server: Optional[str] = None, + count: Optional[int] = 20, + remote: Optional[bool] = False, + **kwargs, + ) -> List[str]: + """ + 获取媒体服务器最新入库条目的图片 + """ + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return [] + return server_obj.get_latest_backdrops(num=count, remote=remote) or [] + + def mediaserver_image_cookies( + self, + server: Optional[str] = None, + image_url: Optional[str] = None, + **kwargs, + ) -> Optional[str | dict]: + """ + 获取绿联影视服务器的图片Cookies + """ + if not image_url: + return None + if server: + server_obj: Optional[Ugreen] = self.get_instance(server) + if not server_obj: + return None + return server_obj.get_image_cookies(image_url) + for server_obj in self.get_instances().values(): + if cookies := server_obj.get_image_cookies(image_url): + return cookies + return None diff --git a/app/modules/ugreen/ugreen.py b/app/modules/ugreen/ugreen.py index 5f3730c91..0492960c7 100644 --- a/app/modules/ugreen/ugreen.py +++ b/app/modules/ugreen/ugreen.py @@ -12,7 +12,7 @@ from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibr from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.application.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper from app.runtime.log import logger from app.modules.ugreen.api import Api diff --git a/app/monitor/__init__.py b/app/monitor/__init__.py index f16791c88..a5e4a47cf 100644 --- a/app/monitor/__init__.py +++ b/app/monitor/__init__.py @@ -1,15 +1,28 @@ -""" -目录监控包。 +"""目录监控公开门面,具体对象按需解析。""" -- watcher.py 本地目录监控线程(watchfiles) -- syslimits.py 系统限制探测与监控模式决策 -- snapshot.py 远程快照存取与比对 -- dispatcher.py 监控事件到整理链的分发 -- poller.py 远程目录轮询监控 -- recovery.py 触碰挂载的恢复动作的可放弃执行单元(block 型故障隔离) -- monitor.py Monitor 门面:装配、生命周期与健康检查 -""" -from app.monitor.watcher import DirectoryChangeEvent, LocalDirectoryWatcher -from app.monitor.monitor import Monitor +from importlib import import_module +from typing import Any + + +_EXPORT_MODULES = { + "DirectoryChangeEvent": "app.monitor.watcher", + "LocalDirectoryWatcher": "app.monitor.watcher", + "Monitor": "app.monitor.monitor", +} + + +def __getattr__(name: str) -> Any: + """首次访问公开监控对象时只加载其所属实现模块。""" + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module 'app.monitor' has no attribute {name!r}") + value = getattr(import_module(module_name), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """让惰性公开对象继续支持交互式发现。""" + return sorted(set(globals()) | set(_EXPORT_MODULES)) __all__ = ["DirectoryChangeEvent", "LocalDirectoryWatcher", "Monitor"] diff --git a/app/monitor/dispatcher.py b/app/monitor/dispatcher.py index c0608d84c..7ce0d68cd 100644 --- a/app/monitor/dispatcher.py +++ b/app/monitor/dispatcher.py @@ -7,11 +7,16 @@ from typing import Any, Dict, List, Optional, Tuple from app.chain.transfer import TransferChain from app.runtime.cache import TTLCache from app.runtime.config import settings -from app.db.oper.transferhistory import TransferHistoryOper from app.application.directory import DirectoryHelper -from app.application.history import (HistoryGateAction, describe_history_gate, - evaluate_history_gate, is_skip_action, - max_failed_retries, resolve_history) +from app.application.history import ( + HistoryGateAction, + TransferHistoryPort as TransferHistoryOper, + describe_history_gate, + evaluate_history_gate, + is_skip_action, + max_failed_retries, + resolve_history, +) from app.runtime.log import logger from app.adapters.system.fsproxy import fsproxy from app.schemas.workflow import FileItem diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index 07071009f..8685facc0 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -435,10 +435,10 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { owner="runtime", ), "app.core.security": ModuleAlias( - target="app.application.security.access", - replacement="app.application.security.access", + target="app.sdk.security", + replacement="app.sdk.security", introduced="v3.0.0", - owner="application", + owner="sdk", ), "app.helper.agent": ModuleAlias( target="app.application.messaging.agent", replacement="app.application.messaging.agent", @@ -569,10 +569,16 @@ MODULE_ALIASES: Dict[str, ModuleAlias] = { introduced="v3.0.0", owner="adapters", ), "app.helper.service": ModuleAlias( - target="app.runtime.extensions.service_registry", + target="app.sdk.services", replacement="app.sdk.services", introduced="v3.0.0", owner="runtime", ), + "app.runtime.extensions.service_registry": ModuleAlias( + target="app.sdk.services", + replacement="app.sdk.services", + introduced="v3.0.0", + owner="sdk", + ), "app.helper.sites": ModuleAlias( target="app.application.site.sites", replacement="app.sdk.network", introduced="v3.0.0", owner="application", diff --git a/app/runtime/config.py b/app/runtime/config.py index 1ed351457..13b651509 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -5,6 +5,7 @@ import os import platform import re import secrets +import shutil import sys import threading from asyncio import AbstractEventLoop @@ -25,7 +26,12 @@ from app.runtime.log import ( NonBlockingFileHandler, ) from app.schemas.types import MediaType -from app.adapters.system.host import SystemUtils +from app.foundation.environment import ( + cpu_arch, + get_env_path, + is_docker, + is_frozen, +) from app.foundation.url import UrlUtils from version import APP_VERSION @@ -729,7 +735,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): model_config = SettingsConfigDict( case_sensitive=True, - env_file=SystemUtils.get_env_path(), + env_file=get_env_path(), env_file_encoding="utf-8", ) @@ -741,10 +747,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): if not path.exists(): path.mkdir(parents=True, exist_ok=True) # 如果是二进制程序,确保配置文件存在 - if SystemUtils.is_frozen(): + if is_frozen(): app_env_path = self.CONFIG_PATH / "app.env" if not app_env_path.exists(): - SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", app_env_path) + shutil.copy2(self.INNER_CONFIG_PATH / "app.env", app_env_path) @staticmethod def validate_api_token(value: Any, original_value: Any) -> Tuple[Any, bool]: @@ -928,7 +934,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): # 当值为 None 时,从 env 文件中删除该键,恢复为默认值 if converted_value is None: unset_key( - dotenv_path=SystemUtils.get_env_path(), + dotenv_path=get_env_path(), key_to_unset=field_name, ) logger.info(f"配置项 '{field_name}' 已清空,从 'app.env' 中移除") @@ -940,7 +946,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): value_to_write = str(converted_value) set_key( - dotenv_path=SystemUtils.get_env_path(), + dotenv_path=get_env_path(), key_to_set=field_name, value_to_set=value_to_write, quote_mode="always", @@ -1013,7 +1019,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): """ 全局用户代理字符串 """ - return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})" + return ( + f"{self.PROJECT_NAME}/{APP_VERSION[1:]} " + f"({platform.system()} {platform.release()}; {cpu_arch()})" + ) @property def NORMAL_USER_AGENT(self) -> str: @@ -1032,9 +1041,9 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): """按显式配置、容器和冻结运行环境确定配置目录。""" if self.CONFIG_DIR: return Path(self.CONFIG_DIR) - elif SystemUtils.is_docker(): + elif is_docker(): return Path("/config") - elif SystemUtils.is_frozen(): + elif is_frozen(): return Path(sys.executable).parent / "config" return self.ROOT_PATH / "config" diff --git a/app/runtime/event/dispatch.py b/app/runtime/event/dispatch.py index 31b023364..8c2829966 100644 --- a/app/runtime/event/dispatch.py +++ b/app/runtime/event/dispatch.py @@ -8,10 +8,9 @@ import time from collections.abc import Callable from typing import Any -from fastapi.concurrency import run_in_threadpool - from app.runtime.event.binding import EventBindingResolver from app.runtime.event.registry import EventRegistry +from app.runtime.execution import run_in_threadpool from app.runtime.log import logger from app.schemas.types import EventType diff --git a/app/runtime/execution.py b/app/runtime/execution.py index 059259a8c..7b5992660 100644 --- a/app/runtime/execution.py +++ b/app/runtime/execution.py @@ -1,10 +1,22 @@ import asyncio import inspect import time -from functools import wraps +from functools import partial, wraps from typing import Any, Callable from app.schemas.exception import ImmediateException +from anyio.to_thread import run_sync + + +async def run_in_threadpool( + func: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> Any: + """在线程中执行同步函数,保持 FastAPI 旧帮助函数的参数语义。""" + if kwargs: + func = partial(func, **kwargs) + return await run_sync(func, *args) def retry(ExceptionToCheck: Any, diff --git a/app/runtime/extensions/module/dispatcher.py b/app/runtime/extensions/module/dispatcher.py index b586942d2..4c5c43d50 100644 --- a/app/runtime/extensions/module/dispatcher.py +++ b/app/runtime/extensions/module/dispatcher.py @@ -6,9 +6,8 @@ import inspect from collections.abc import Callable, Mapping from typing import Any, Protocol -from fastapi.concurrency import run_in_threadpool - from app.foundation.reflection import ObjectUtils +from app.runtime.execution import run_in_threadpool from app.runtime.log import logger from app.runtime.extensions.module.contracts import get_module_method_contract from app.schemas.exception import RateLimitExceededException diff --git a/app/runtime/extensions/plugin/access.py b/app/runtime/extensions/plugin/access.py new file mode 100644 index 000000000..a34106ae6 --- /dev/null +++ b/app/runtime/extensions/plugin/access.py @@ -0,0 +1,59 @@ +"""插件可见性和特殊密钥权限策略。""" + +import os +from collections.abc import Callable +from typing import Any, Optional + + +class PluginAccessPolicy: + """根据站点认证等级和插件公钥判断插件是否可投影。""" + + def __init__( + self, + *, + auth_level: Callable[[], int], + verify_keys: Callable[..., bool], + log: Any, + ) -> None: + """保存认证等级、密钥校验和日志端口。""" + self._auth_level = auth_level + self._verify_keys = verify_keys + self._logger = log + + @staticmethod + def private_key(plugin_id: str) -> Optional[str]: + """按插件 ID 读取特殊密钥认证使用的环境变量。""" + try: + return os.environ.get(f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY") + except Exception: + return None + + def check(self, plugin: Any, source: Optional[Any] = None) -> bool: + """设置插件认证等级并判断当前环境是否允许该插件。""" + if source: + if isinstance(source, dict) and "level" in source: + plugin.auth_level = source.get("level") + elif hasattr(source, "auth_level"): + plugin.auth_level = source.auth_level + elif not hasattr(plugin, "auth_level"): + return True + + level = self._auth_level() + if ( + level > 1 + and plugin.auth_level == 99 + and hasattr(plugin, "plugin_public_key") + ): + plugin_id = ( + getattr(plugin, "id", None) + if not isinstance(plugin, type) + else plugin.__name__ + ) + public_key = plugin.plugin_public_key + if public_key and plugin_id: + private_key = self.private_key(plugin_id) + return self._verify_keys( + public_key=public_key, + private_key=private_key, + ) + return level >= plugin.auth_level diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py new file mode 100644 index 000000000..3ca77632e --- /dev/null +++ b/app/runtime/extensions/plugin/catalog.py @@ -0,0 +1,204 @@ +"""插件本地运行态和远程市场目录投影。""" + +from __future__ import annotations + +import importlib.util +from collections.abc import Callable, Mapping +from typing import Any, Optional + +from app.foundation.version import compare_version +from app.runtime.config import settings +from app.runtime.extensions.plugin.contracts import supports_plugin_hook +from app.runtime.extensions.plugin.storage import PluginStorage +from app.runtime.extensions.plugin.system import PluginSystemServices +from app.schemas.plugin import Plugin +from app.schemas.types import SystemConfigKey + + +class PluginCatalogFacade: + """把插件目录应用服务与运行态注册表连接起来。""" + + def __init__( + self, + *, + classes: Callable[[], Mapping[str, Any]], + running: Callable[[], Mapping[str, Any]], + storage: Callable[[], PluginStorage], + system: Callable[[], PluginSystemServices], + market_catalog: Callable[[], Any], + market_loader: Callable[..., Any], + async_market_loader: Callable[..., Any], + map_plugin: Callable[..., Optional[Plugin]], + auth_checker: Callable[..., bool], + plugin_attr: Callable[[str, str], Any], + log: Any, + ) -> None: + """保存注册表、目录服务和插件外部系统端口。""" + self._classes = classes + self._running = running + self._storage = storage + self._system = system + self._market_catalog = market_catalog + self._market_loader = market_loader + self._async_market_loader = async_market_loader + self._map_plugin = map_plugin + self._auth_checker = auth_checker + self._plugin_attr = plugin_attr + self._logger = log + + def online(self, force: bool = False) -> list[Plugin]: + """读取所有兼容代际的在线插件目录。""" + if not settings.PLUGIN_MARKET: + return [] + markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] + result = self._market_catalog().collect( + markets=markets, + compatible_flags=self._system().compatible_flags(settings.VERSION_FLAG), + force=force, + loader=self._market_loader, + ) + self._logger.info(f"获取到 {len(result)} 个线上插件") + return result + + def local(self) -> list[Plugin]: + """把已加载插件投影为本地插件目录 DTO。""" + installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + plugins: list[Plugin] = [] + for plugin_id, plugin_class in self._classes().items(): + plugin_instance = self._running().get(plugin_id) + plugin = Plugin( + id=plugin_id, + installed=plugin_id in installed, + state=self._safe_state(plugin_id, plugin_instance), + has_page=supports_plugin_hook(plugin_class, "get_page"), + plugin_public_key=getattr(plugin_class, "plugin_public_key", None), + plugin_name=getattr(plugin_class, "plugin_name", None), + plugin_desc=getattr(plugin_class, "plugin_desc", None), + plugin_version=getattr(plugin_class, "plugin_version", None), + plugin_icon=getattr(plugin_class, "plugin_icon", None), + plugin_author=getattr(plugin_class, "plugin_author", None), + author_url=getattr(plugin_class, "author_url", None), + plugin_order=getattr(plugin_class, "plugin_order", 0), + has_update=False, + is_local=True, + ) + if not self._auth_checker(plugin=plugin, source=plugin_class): + continue + plugins.append(plugin) + plugins.sort(key=lambda item: getattr(item, "plugin_order", 0)) + return plugins + + def local_version(self, plugin_id: str) -> Optional[str]: + """读取指定已安装插件版本,不触发全量目录投影。""" + installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + if plugin_id not in installed: + return None + plugin_class = self._classes().get(plugin_id) + return getattr(plugin_class, "plugin_version", None) + + def local_repository(self) -> list[Plugin]: + """读取本地插件仓候选并映射为目录 DTO。""" + installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or [] + candidates = self._system().local_candidates() + plugins: list[Plugin] = [] + for plugin_id, info in candidates.items(): + package_version = info.get("package_version") + plugin = self._map_plugin( + pid=plugin_id, + plugin_info=info, + market=self._system().local_repo_url( + plugin_id, + info.get("repo_path"), + package_version, + ), + installed_apps=installed, + add_time=0, + package_version=package_version, + ) + if plugin: + plugin.is_local = True + plugins.append(plugin) + plugins.sort(key=lambda item: getattr(item, "plugin_order", 0)) + self._logger.info(f"获取到 {len(plugins)} 个本地插件") + return plugins + + def exists(self, plugin_id: str, version: Optional[str] = None) -> bool: + """判断插件包和已加载版本是否满足安装前置条件。""" + if not plugin_id: + return False + try: + package_name = f"app.plugins.{plugin_id.lower()}" + spec = importlib.util.find_spec(package_name) + if spec is None or spec.origin is None: + return False + local_version = self._plugin_attr(plugin_id, "plugin_version") + if not local_version: + return False + if version and not compare_version(local_version, ">=", version): + self._logger.warning( + f"Plugin {plugin_id} version: {local_version} " + f"(older than version: {version})" + ) + return False + return True + except Exception as error: + self._logger.debug(f"获取插件是否在本地包中存在失败,{error}") + return False + + def get_from_market( + self, + market: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> list[Plugin]: + """读取并映射指定插件市场。""" + return self._market_catalog().load(market, package_version, force) + + async def async_online( + self, + force: bool = False, + progress_callback: Optional[Callable[..., None]] = None, + ) -> list[Plugin]: + """异步读取所有兼容代际的在线插件目录。""" + if not settings.PLUGIN_MARKET: + if progress_callback: + progress_callback(value=100, text="未配置插件市场,跳过刷新") + return [] + markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] + result = await self._market_catalog().async_collect( + markets=markets, + compatible_flags=self._system().compatible_flags(settings.VERSION_FLAG), + force=force, + loader=self._async_market_loader, + progress_callback=progress_callback, + ) + self._logger.info(f"获取到 {len(result)} 个线上插件") + return result + + async def async_get_from_market( + self, + market: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> list[Plugin]: + """异步读取并映射指定插件市场。""" + return await self._market_catalog().async_load( + market, + package_version, + force, + ) + + def merge(self, higher: list[Plugin], base: list[Plugin]) -> list[Plugin]: + """合并不同代际插件目录并保留市场优先级。""" + markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] + return self._market_catalog().merge(higher, base, markets) + + def _safe_state(self, plugin_id: str, plugin: Any) -> bool: + """读取插件状态,单个插件异常不阻断整个本地目录。""" + if not plugin or not hasattr(plugin, "get_state"): + return False + try: + return bool(plugin.get_state()) + except Exception as error: + self._logger.error(f"获取插件 {plugin_id} 状态出错:{error}") + return False diff --git a/app/runtime/extensions/plugin/clone.py b/app/runtime/extensions/plugin/clone.py new file mode 100644 index 000000000..06a9d2887 --- /dev/null +++ b/app/runtime/extensions/plugin/clone.py @@ -0,0 +1,96 @@ +"""插件分身创建运行时用例。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + + +class PluginCloneService: + """协调插件包复制、安装清单、配置复制和运行态刷新。""" + + def __init__( + self, + *, + plugin_class: Callable[[str], Optional[Any]], + plugin_exists: Callable[[str], bool], + package_clone: Callable[..., tuple[bool, str]], + installed_plugins: Callable[[], list[str]], + save_installed_plugins: Callable[[list[str]], Any], + read_config: Callable[[str], dict], + save_config: Callable[[str, dict], bool], + reload_plugin: Callable[[str], Any], + running_plugin: Callable[[str], Optional[Any]], + initialize_plugin: Callable[[str, dict], Any], + log: Any, + ) -> None: + """保存包、持久化和运行态端口。""" + self._plugin_class = plugin_class + self._plugin_exists = plugin_exists + self._package_clone = package_clone + self._installed_plugins = installed_plugins + self._save_installed_plugins = save_installed_plugins + self._read_config = read_config + self._save_config = save_config + self._reload_plugin = reload_plugin + self._running_plugin = running_plugin + self._initialize_plugin = initialize_plugin + self._logger = log + + def clone( + self, + *, + plugin_id: str, + suffix: str, + name: str, + description: str, + version: Optional[str] = None, + icon: Optional[str] = None, + ) -> tuple[bool, str]: + """创建插件分身并保持原有默认禁用配置语义。""" + if not plugin_id or not suffix: + return False, "插件ID和分身后缀不能为空" + original_class = self._plugin_class(plugin_id) + if original_class is None: + return False, f"原插件 {plugin_id} 不存在" + + clone_id = f"{plugin_id}{suffix.lower()}" + if self._plugin_exists(clone_id): + return False, f"分身插件 {clone_id} 已存在" + + try: + success, message = self._package_clone( + plugin_id=plugin_id, + clone_id=clone_id, + original_class_name=original_class.__name__, + suffix=suffix.lower(), + name=name, + description=description, + version=version, + icon=icon, + ) + if not success: + return False, message + + installed = list(self._installed_plugins()) + if clone_id not in installed: + installed.append(clone_id) + self._save_installed_plugins(installed) + + original_config = self._read_config(plugin_id) + if original_config: + clone_config = dict(original_config) + clone_config["enable"] = False + clone_config["enabled"] = False + self._save_config(clone_id, clone_config) + + self._reload_plugin(clone_id) + clone_instance = self._running_plugin(clone_id) + clone_config = self._read_config(clone_id) + if clone_instance and clone_config: + self._initialize_plugin(clone_id, clone_config) + self._logger.info(f"插件分身 {clone_id} 创建成功") + return True, clone_id + except Exception as error: # noqa: BLE001 + self._logger.error(f"创建插件分身失败:{error}") + return False, f"创建插件分身失败:{error}" diff --git a/app/runtime/extensions/plugin/contracts.py b/app/runtime/extensions/plugin/contracts.py index ab9dbd463..716995cc8 100644 --- a/app/runtime/extensions/plugin/contracts.py +++ b/app/runtime/extensions/plugin/contracts.py @@ -6,6 +6,18 @@ from typing import Any from app.foundation.reflection import ObjectUtils +class PluginRuntimeError(Exception): + """插件运行时调用失败的基础异常。""" + + +class PluginNotFoundError(PluginRuntimeError): + """目标插件未加载。""" + + +class PluginDashboardError(PluginRuntimeError): + """插件仪表板返回值不符合宿主契约。""" + + @dataclass(frozen=True) class PluginHookContract: """描述宿主识别一个插件钩子时必须保持的运行语义。""" diff --git a/app/runtime/extensions/plugin/dependency.py b/app/runtime/extensions/plugin/dependency.py new file mode 100644 index 000000000..1bef06c6f --- /dev/null +++ b/app/runtime/extensions/plugin/dependency.py @@ -0,0 +1,42 @@ +"""插件依赖检查与安装运行时服务。""" + +import time +from collections.abc import Callable +from typing import Any + +from app.runtime.extensions.plugin.system import PluginSystemServices + + +class PluginDependencyService: + """执行缺失插件依赖的发现和安装,不参与插件生命周期。""" + + def __init__( + self, + *, + system: Callable[[], PluginSystemServices], + log: Any, + ) -> None: + """保存插件系统适配器和日志端口。""" + self._system = system + self._logger = log + + def install_missing(self) -> list[str]: + """安装当前环境缺失的插件依赖并返回检查到的依赖名。""" + installer = self._system().dependency + missing = installer.find_missing() + if not missing: + return missing + self._logger.debug(f"检测到缺失的依赖项: {missing}") + self._logger.info(f"开始安装缺失的依赖项,共 {len(missing)} 个...") + started = time.time() + success, _message = installer.install(missing) + elapsed = time.time() - started + if success: + self._logger.info( + f"已完成 {len(missing)} 个依赖项安装,总耗时:{elapsed:.2f} 秒" + ) + else: + self._logger.warning( + f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{elapsed:.2f} 秒" + ) + return missing diff --git a/app/runtime/extensions/plugin/lifecycle.py b/app/runtime/extensions/plugin/lifecycle.py new file mode 100644 index 000000000..c83b474e9 --- /dev/null +++ b/app/runtime/extensions/plugin/lifecycle.py @@ -0,0 +1,133 @@ +"""插件实例生命周期应用能力。""" + +from __future__ import annotations + +import traceback +from collections.abc import Callable +from typing import Any, Optional + + +class PluginLifecycle: + """管理插件发现、初始化、启停和热重载,不持有市场或 HTTP 路由职责。""" + + def __init__( + self, + *, + classes: dict[str, Any], + running: dict[str, Any], + load_plugins: Callable[[Optional[str], list[str], Callable[[Any], bool]], list[Any]], + installed_plugins: Callable[[], list[str]], + plugin_config: Callable[[str], dict], + auth_checker: Callable[[Any], bool], + clear_modules: Callable[[Optional[str]], Any], + clear_tools: Callable[[], None], + enable_events: Callable[[Any], None], + disable_events: Callable[[Any], None], + log: Any, + event_sender: Callable[..., Any], + ) -> None: + """保存注册表、加载器和事件端口。""" + self._classes = classes + self._running = running + self._load_plugins = load_plugins + self._installed_plugins = installed_plugins + self._plugin_config = plugin_config + self._auth_checker = auth_checker + self._clear_modules = clear_modules + self._clear_tools = clear_tools + self._enable_events = enable_events + self._disable_events = disable_events + self._logger = log + self._event_sender = event_sender + + def start(self, plugin_id: Optional[str] = None) -> None: + """加载并初始化指定插件或全部已安装插件。""" + installed_plugins = self._installed_plugins() + + def check_module(module: Any) -> bool: + """判断模块是否具备宿主插件最小生命周期钩子。""" + return hasattr(module, "init_plugin") and hasattr(module, "plugin_name") + + plugins = self._load_plugins(plugin_id, installed_plugins, check_module) + plugins.sort(key=lambda item: getattr(item, "plugin_order", 0)) + for plugin in plugins: + current_id = plugin.__name__ + if plugin_id and current_id != plugin_id: + continue + try: + if not self._auth_checker(plugin): + if current_id in self._classes: + self._classes[current_id] = plugin + continue + self._classes[current_id] = plugin + instance = plugin() + instance.init_plugin(self._plugin_config(current_id)) + self._running[current_id] = instance + self._logger.info( + f"加载插件:{current_id} 版本:{instance.plugin_version}" + ) + if instance.get_state(): + self._enable_events(plugin) + else: + self._disable_events(plugin) + except Exception as error: # noqa: BLE001 + self._logger.error( + f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}" + ) + self._clear_tools() + + def initialize(self, plugin_id: str, config: dict) -> None: + """重新应用指定插件配置并刷新事件注册状态。""" + plugin = self._running.get(plugin_id) + if not plugin: + return + plugin.init_plugin(config) + if plugin.get_state(): + self._enable_events(type(plugin)) + else: + self._disable_events(type(plugin)) + self._clear_tools() + + def stop(self, plugin_id: Optional[str] = None) -> None: + """停止指定插件或全部插件,并清理模块缓存。""" + if plugin_id: + self._logger.info(f"正在停止插件 {plugin_id}...") + plugin = self._running.get(plugin_id) + plugins = {plugin_id: plugin} if plugin else {} + if not plugin: + self._logger.debug(f"插件 {plugin_id} 不存在或未加载") + else: + self._logger.info("正在停止所有插件...") + plugins = dict(self._running) + + for current_id, plugin in plugins.items(): + self._disable_events(type(plugin)) + self._stop_plugin(plugin) + + if plugin_id: + self._classes.pop(plugin_id, None) + self._running.pop(plugin_id, None) + self._clear_modules(plugin_id) + else: + self._classes.clear() + self._running.clear() + self._clear_modules(None) + self._clear_tools() + self._logger.info("插件停止完成") + + def reload(self, plugin_id: str, reload_event: Any) -> None: + """重启指定插件并广播插件重载事件。""" + self.stop(plugin_id) + self.start(plugin_id) + self._event_sender(reload_event, data={"plugin_id": plugin_id}) + + def _stop_plugin(self, plugin: Any) -> None: + """按插件旧 ABI 顺序关闭资源和服务。""" + try: + if hasattr(plugin, "close"): + plugin.close() + if hasattr(plugin, "stop_service"): + plugin.stop_service() + except Exception as error: # noqa: BLE001 + name = plugin.get_name() if hasattr(plugin, "get_name") else type(plugin).__name__ + self._logger.warning(f"停止插件 {name} 时发生错误: {error}") diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py new file mode 100644 index 000000000..f553fa652 --- /dev/null +++ b/app/runtime/extensions/plugin/loader.py @@ -0,0 +1,123 @@ +"""插件源码发现、导入和模块缓存清理。""" + +from __future__ import annotations + +import importlib +import sys +import traceback +from collections.abc import Callable +from pathlib import Path +from typing import Any, Optional + + +PluginImportPreparer = Callable[..., None] +PluginImportScanner = Callable[..., None] +PluginValidator = Callable[[Any], bool] + + +class PluginLoader: + """只负责从运行目录发现插件类,并维护对应模块缓存。""" + + def __init__( + self, + *, + plugins_root: Path, + import_preparer: PluginImportPreparer, + import_scanner: PluginImportScanner, + log: Any, + ) -> None: + """保存插件目录、导入前置能力和日志端口。""" + self._plugins_root = plugins_root + self._import_preparer = import_preparer + self._import_scanner = import_scanner + self._logger = log + + def load( + self, + plugin_id: Optional[str], + installed_plugins: list[str], + validator: PluginValidator, + ) -> list[Any]: + """只导入指定插件或已安装插件,并返回通过契约检查的插件类。""" + if not self._plugins_root.exists(): + self._logger.warning(f"插件目录不存在:{self._plugins_root}") + return [] + + targets = ( + [plugin_id.lower()] + if plugin_id + else [item.lower() for item in installed_plugins] + ) + if not targets: + self._logger.debug("没有需要加载的插件") + return [] + + plugins = [] + loaded_classes = set() + for plugin_dir in self._plugins_root.iterdir(): + if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"): + continue + if plugin_dir.name not in targets: + self._logger.debug( + f"跳过插件目录:{plugin_dir.name}(不在加载列表中)" + ) + continue + if not (plugin_dir / "__init__.py").exists(): + self._logger.debug( + f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)" + ) + continue + + try: + module_name = f"app.plugins.{plugin_dir.name}" + self._logger.debug(f"正在导入插件模块:{module_name}") + self._import_preparer( + plugin_id=plugin_dir.name, + plugin_dir=plugin_dir, + ) + self._import_scanner( + plugin_id=plugin_dir.name, + plugin_dir=plugin_dir, + ) + module = importlib.import_module(module_name) + for name, candidate in module.__dict__.items(): + if name.startswith("_") or not isinstance(candidate, type): + continue + if name in loaded_classes or not validator(candidate): + continue + loaded_classes.add(name) + plugins.append(candidate) + self._logger.debug(f"找到符合条件的插件类:{name}") + break + except Exception as err: + self._logger.error( + f"加载插件 {plugin_dir.name} 失败:{str(err)} - " + f"{traceback.format_exc()}" + ) + return plugins + + def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]: + """清除指定插件或全部插件的 Python 模块缓存。""" + prefix = ( + f"app.plugins.{plugin_id.lower()}" + if plugin_id + else "app.plugins" + ) + removed = [ + module_name + for module_name in list(sys.modules) + if module_name == prefix or module_name.startswith(f"{prefix}.") + ] + for module_name in removed: + sys.modules.pop(module_name, None) + self._logger.debug(f"已清除插件模块缓存:{module_name}") + importlib.invalidate_caches() + self._logger.debug("已清除查找器的缓存") + if plugin_id: + if removed: + self._logger.info( + f"插件 {plugin_id} 共清除 {len(removed)} 个模块缓存:{removed}" + ) + else: + self._logger.debug(f"插件 {plugin_id} 没有找到需要清除的模块缓存") + return removed diff --git a/app/runtime/extensions/plugin/metadata.py b/app/runtime/extensions/plugin/metadata.py new file mode 100644 index 000000000..e0c0eae02 --- /dev/null +++ b/app/runtime/extensions/plugin/metadata.py @@ -0,0 +1,116 @@ +"""插件目录条目的运行态元数据映射。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +from app.runtime.extensions.plugin.contracts import supports_plugin_hook +from app.schemas.plugin import Plugin + + +class PluginMetadataMapper: + """把市场或本地仓条目映射为包含运行态状态的插件 DTO。""" + + def __init__( + self, + *, + plugin_instance: Callable[[str], Optional[Any]], + plugin_class: Callable[[str], Optional[Any]], + annotate_system_version: Callable[[dict], dict], + is_package_compatible: Callable[[dict, str], bool], + auth_checker: Callable[[Plugin, dict], bool], + version_compare: Callable[[str, str, str], bool], + log: Any, + ) -> None: + """保存注册表、兼容判断和权限判断端口。""" + self._plugin_instance = plugin_instance + self._plugin_class = plugin_class + self._annotate_system_version = annotate_system_version + self._is_package_compatible = is_package_compatible + self._auth_checker = auth_checker + self._version_compare = version_compare + self._logger = log + + def map( + self, + plugin_id: str, + plugin_info: dict, + market: str, + installed_plugins: list[str], + add_time: int, + package_version: Optional[str] = None, + ) -> Optional[Plugin]: + """映射一个插件索引条目,不兼容或无权限时返回空。""" + if not isinstance(plugin_info, dict): + return None + info = self._annotate_system_version(plugin_info.copy()) + if not self._is_package_compatible(info, package_version or ""): + return None + + instance = self._plugin_instance(plugin_id) + plugin_class = self._plugin_class(plugin_id) + plugin = Plugin(id=plugin_id) + plugin.installed = plugin_id in installed_plugins and plugin_class is not None + plugin.has_update = False + if plugin_class: + installed_version = getattr(plugin_class, "plugin_version", None) + online_version = info.get("version") + if installed_version and online_version: + plugin.has_update = self._version_compare( + installed_version, + "<", + online_version, + ) + + plugin.system_version = info.get("system_version") + if info.get("system_version_compatible") is False: + plugin.system_version_compatible = False + plugin.system_version_message = info.get("system_version_message") + + plugin.state = self._state(plugin_id, instance) + plugin.has_page = bool( + instance and supports_plugin_hook(instance, "get_page") + ) + if info.get("key"): + plugin.plugin_public_key = info["key"] + if not self._auth_checker(plugin, info): + return None + + plugin.plugin_name = info.get("name") + plugin.plugin_desc = info.get("description") + plugin.plugin_version = info.get("version") + plugin.plugin_icon = info.get("icon") + plugin.plugin_label = self.normalize_label(info.get("labels")) + plugin.plugin_author = info.get("author") + plugin.history = info.get("history") or {} + plugin.release = bool(info.get("release")) + plugin.repo_url = market + plugin.is_local = False + plugin.add_time = add_time + return plugin + + def _state(self, plugin_id: str, instance: Optional[Any]) -> bool: + """安全读取插件运行状态,插件异常时降级为未启用。""" + if not instance or not hasattr(instance, "get_state"): + return False + try: + return bool(instance.get_state()) + except Exception as error: # noqa: BLE001 + self._logger.error(f"获取插件 {plugin_id} 状态出错:{error}") + return False + + @staticmethod + def normalize_label(labels: Any) -> Optional[str]: + """兼容市场标签的旧字符串和新列表格式。""" + if isinstance(labels, str): + label = labels.strip() + return label or None + if isinstance(labels, list): + normalized = [ + str(item).strip() + for item in labels + if str(item).strip() + ] + return " ".join(normalized) or None + return None diff --git a/app/runtime/extensions/plugin/monitor.py b/app/runtime/extensions/plugin/monitor.py new file mode 100644 index 000000000..3e3988c0c --- /dev/null +++ b/app/runtime/extensions/plugin/monitor.py @@ -0,0 +1,220 @@ +"""插件运行目录与本地仓库的文件变化监控。""" + +from __future__ import annotations + +import time +import threading +from collections.abc import Callable +from pathlib import Path +from typing import Any, Optional + + +FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]] +RuntimePluginResolver = Callable[[Path], Optional[str]] +LocalCandidateResolver = Callable[[Path], Optional[dict]] +LocalPluginSync = Callable[[str, Optional[dict]], bool] +PluginReloader = Callable[[str], Any] +WatchFunction = Callable[..., Any] + + +class PluginMonitorController: + """独立管理插件文件监控线程的启动、停止和重建。""" + + def __init__(self, *, runner: Callable[[], None], log: Any) -> None: + """保存监控循环入口和日志端口,线程状态仅由本组件持有。""" + self._runner = runner + self._logger = log + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + @property + def stop_event(self) -> threading.Event: + """返回供 watchfiles 监听的停止事件。""" + return self._stop_event + + def reload(self, enabled: bool) -> None: + """按当前配置停止旧线程,并在启用时创建新线程。""" + self.stop() + if enabled: + self.start() + + def start(self) -> None: + """启动唯一的守护监控线程。""" + if self._thread and self._thread.is_alive(): + self._logger.info("插件文件修改监测已经在运行中...") + return + self._logger.info("开始监测插件文件修改...") + self._stop_event.clear() + self._thread = threading.Thread(target=self._runner, daemon=True) + self._thread.start() + + def stop(self) -> None: + """请求监控线程退出,并在限定时间内等待其清理。""" + if not self._thread or not self._thread.is_alive(): + self._logger.info("未启用插件文件修改监测,无需停止") + return + self._logger.info("正在停止插件文件修改监测...") + self._stop_event.set() + self._thread.join(timeout=5) + if self._thread.is_alive(): + self._logger.warning("插件文件修改监测线程在5秒内未能正常停止。") + self._thread = None + self._logger.info("插件文件修改监测停止完成") + + +class PluginChangeMonitor: + """把文件变化归并为本地同步和运行态重载动作。""" + + def __init__( + self, + *, + runtime_root: Path, + local_roots: Callable[[], list[Path]], + stop_event: Any, + recent_sync: dict[str, float], + federated_change: FederatedChangeResolver, + runtime_plugin: RuntimePluginResolver, + local_candidate: LocalCandidateResolver, + sync_local: LocalPluginSync, + reload_plugin: PluginReloader, + watch: WatchFunction, + log: Any, + ) -> None: + """保存监控路径、变化解析器和副作用回调。""" + self._runtime_root = runtime_root + self._local_roots = local_roots + self._stop_event = stop_event + self._recent_sync = recent_sync + self._federated_change = federated_change + self._runtime_plugin = runtime_plugin + self._local_candidate = local_candidate + self._sync_local = sync_local + self._reload_plugin = reload_plugin + self._watch = watch + self._logger = log + + def run(self) -> None: + """运行 watchfiles 主循环并按批次同步、重载插件。""" + plugin_paths = [str(self._runtime_root)] + plugin_paths.extend( + str(path) + for path in self._local_roots() + if path.exists() and path.is_dir() + ) + self._logger.info(">>> 监控线程已启动,准备进入watch循环...") + for changes in self._watch( + *plugin_paths, + stop_event=self._stop_event, + rust_timeout=1000, + yield_on_timeout=True, + ): + if not changes: + continue + self._process_changes(changes) + + def _process_changes(self, changes: Any) -> None: + """把一批文件事件归并为最多一次同步和一次重载。""" + plugins_to_reload = set() + local_plugins_to_sync = {} + for _change_type, path_str in changes: + event_path = Path(path_str) + if "__pycache__" in event_path.parts: + continue + if event_path.name == "requirements.txt": + self._handle_requirements_change(event_path) + continue + + federated_change = self._federated_change(event_path) + if federated_change: + plugin_id, candidate, remote_entry_ready = federated_change + if candidate and remote_entry_ready: + if candidate.get("compatible") is False: + self._logger.info( + f"检测到本地插件 {plugin_id} 联邦构建产物变化," + f"但跳过同步:{candidate.get('skip_reason')}" + ) + elif plugin_id not in local_plugins_to_sync: + local_plugins_to_sync[plugin_id] = ( + candidate, + event_path, + False, + ) + continue + + if event_path.suffix != ".py": + continue + runtime_plugin_id = self._runtime_plugin(event_path) + candidate = ( + self._local_candidate(event_path) + if not runtime_plugin_id + else None + ) + if runtime_plugin_id: + last_sync_time = self._recent_sync.get(runtime_plugin_id) + if last_sync_time and time.time() - last_sync_time < 2: + continue + plugins_to_reload.add(runtime_plugin_id) + elif candidate: + if candidate.get("compatible") is False: + package_version = candidate.get("package_version") + source_root = ( + f"plugins.{package_version}" + if package_version + else "plugins" + ) + self._logger.info( + f"检测到本地插件 {candidate.get('id')} 文件变化," + f"来源:{source_root},文件:{event_path}," + f"但跳过同步:{candidate.get('skip_reason')}" + ) + continue + local_plugins_to_sync[candidate.get("id")] = ( + candidate, + event_path, + True, + ) + + for plugin_id, (candidate, event_path, should_reload) in ( + local_plugins_to_sync.items() + ): + package_version = candidate.get("package_version") + source_root = ( + f"plugins.{package_version}" if package_version else "plugins" + ) + change_name = "Python 文件" if should_reload else "联邦构建产物" + self._logger.info( + f"检测到本地插件 {plugin_id} {change_name}变化," + f"来源:{source_root},文件:{event_path}" + ) + if self._sync_local(plugin_id, candidate) and should_reload: + plugins_to_reload.add(plugin_id) + + if not plugins_to_reload: + return + self._logger.info( + f"检测到插件文件变化,准备重载: {list(plugins_to_reload)}" + ) + for plugin_id in plugins_to_reload: + try: + self._reload_plugin(plugin_id) + except Exception as err: + self._logger.error( + f"插件 {plugin_id} 热重载失败: {err}", + exc_info=True, + ) + + def _handle_requirements_change(self, event_path: Path) -> None: + """记录依赖文件变化,但不在监控线程中隐式安装依赖。""" + candidate = self._local_candidate(event_path) + if not candidate: + return + if candidate.get("compatible") is False: + self._logger.info( + f"检测到本地插件 {candidate.get('id')} 依赖文件变化," + f"但跳过处理:{candidate.get('skip_reason')}" + ) + return + self._logger.warning( + f"检测到本地插件 {candidate.get('id')} 依赖文件变化," + "请重新安装本地插件以安装依赖" + ) diff --git a/app/runtime/extensions/plugin/paths.py b/app/runtime/extensions/plugin/paths.py new file mode 100644 index 000000000..c25532d80 --- /dev/null +++ b/app/runtime/extensions/plugin/paths.py @@ -0,0 +1,146 @@ +"""插件运行目录、本地仓和联邦产物路径解析。""" + +from __future__ import annotations + +import ast +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any, Optional + +from app.runtime.extensions.plugin.system import PluginSystemServices + + +class PluginPathResolver: + """把文件事件解析为插件 ID、本地候选和联邦入口状态。""" + + def __init__( + self, + *, + runtime_root: Path, + running: Callable[[], Mapping[str, Any]], + system: Callable[[], PluginSystemServices], + strict_system_version: Callable[[], bool], + log: Any, + ) -> None: + """保存运行目录和插件市场路径解析端口。""" + self._runtime_root = runtime_root.resolve() + self._running = running + self._system = system + self._strict_system_version = strict_system_version + self._logger = log + + def federated_change( + self, + event_path: Path, + ) -> Optional[tuple[str, Optional[dict], bool]]: + """识别联邦构建产物变化并确认入口文件已完整生成。""" + try: + event_path = event_path.resolve() + candidate = self.local_candidate(event_path) + if candidate: + plugin_id = candidate.get("id") + plugin_dir = Path(candidate.get("path")).resolve() + else: + if not event_path.is_relative_to(self._runtime_root): + return None + relative_parts = event_path.relative_to(self._runtime_root).parts + if not relative_parts: + return None + plugin_dir = self._runtime_root / relative_parts[0] + plugin_id = next( + ( + item + for item in self._running() + if item.lower() == relative_parts[0].lower() + ), + None, + ) + if not plugin_id: + return None + plugin = self._running().get(plugin_id) + if not plugin: + return None + render_mode, dist_path = plugin.get_render_mode() + if render_mode != "vue" or not isinstance(dist_path, str) or not dist_path: + return None + relative_dist_path = Path(dist_path) + if ( + relative_dist_path.is_absolute() + or ".." in relative_dist_path.parts + or "\\" in dist_path + ): + return None + plugin_dir = plugin_dir.resolve() + dist_dir = (plugin_dir / relative_dist_path).resolve() + if ( + dist_dir == plugin_dir + or not dist_dir.is_relative_to(plugin_dir) + or not event_path.is_relative_to(dist_dir) + ): + return None + remote_entry = dist_dir / "remoteEntry.js" + ready = remote_entry.is_file() and remote_entry.resolve().is_relative_to( + plugin_dir + ) + return plugin_id, candidate, ready + except Exception as error: + self._logger.error(f"识别插件联邦构建产物变化时出错: {error}") + return None + + def runtime_plugin(self, event_path: Path) -> Optional[str]: + """从运行目录中的插件 ``__init__.py`` AST 解析插件类名。""" + try: + event_path = event_path.resolve() + if not event_path.is_relative_to(self._runtime_root): + return None + parts = event_path.relative_to(self._runtime_root).parts + if not parts: + return None + init_file = self._runtime_root / parts[0] / "__init__.py" + if not init_file.exists(): + return None + tree = ast.parse( + init_file.read_text(encoding="utf-8", errors="replace") + ) + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + if any( + isinstance(base, ast.Name) and base.id == "_PluginBase" + for base in node.bases + ): + return node.name + return None + except Exception as error: + self._logger.error(f"从路径解析插件 ID 时出错: {error}") + return None + + def local_candidate(self, event_path: Path) -> Optional[dict]: + """按 ``plugins``、``plugins.v2``、``plugins.v3`` 目录解析候选。""" + try: + event_path = event_path.resolve() + for repo_path in self._system().local_repo_paths(): + if not repo_path.exists() or not repo_path.is_dir(): + continue + if not event_path.is_relative_to(repo_path): + continue + parts = event_path.relative_to(repo_path).parts + if len(parts) < 2: + continue + if parts[0] == "plugins": + package_version = "" + elif parts[0].startswith("plugins."): + package_version = parts[0].split(".", 1)[1] + else: + continue + return self._system().local_candidate( + parts[1], + package_version=package_version, + repo_path=repo_path, + strict_compat=False, + strict_system_version=self._strict_system_version(), + ) + return None + except Exception as error: + self._logger.error(f"从本地插件仓路径解析候选时出错: {error}") + return None diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 1488d2f25..5d71670bd 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -1,9 +1,15 @@ """插件公开能力投影。""" +import inspect from typing import Any, Callable, Dict, List, Mapping, Optional -from app.runtime.extensions.plugin.contracts import supports_plugin_hook +from app.runtime.extensions.plugin.contracts import ( + PluginDashboardError, + PluginNotFoundError, + supports_plugin_hook, +) from app.runtime.log import logger as default_logger +from app.schemas.plugin import PluginDashboard class PluginProjection: @@ -260,3 +266,46 @@ class PluginProjection: f"获取插件[{plugin_id}]仪表盘元数据出错:{str(error)}" ) return metadata + + def dashboard( + self, + plugin_id: str, + key: str, + user_agent: Optional[str] = None, + ) -> Optional[PluginDashboard]: + """调用插件仪表板钩子并返回稳定投影,不依赖 HTTP 异常。""" + plugin = self._running_plugins.get(plugin_id) + if not plugin: + raise PluginNotFoundError(f"插件 {plugin_id} 不存在或未加载") + try: + render_mode, _ = plugin.get_render_mode() + method = plugin.get_dashboard + count = len(inspect.signature(method).parameters) + if count > 1: + dashboard = method(key=key, user_agent=user_agent) + elif count > 0: + dashboard = method(user_agent=user_agent) + else: + dashboard = method() + except Exception as error: # noqa: BLE001 + self._logger.error(f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}") + raise PluginDashboardError( + f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}" + ) from error + if dashboard is None: + return None + if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3: + self._logger.error(f"插件 {plugin_id} 返回的仪表盘数据格式错误") + raise PluginDashboardError( + f"插件 {plugin_id} 返回的仪表盘数据格式错误" + ) + cols, attrs, elements = dashboard + return PluginDashboard( + id=plugin_id, + name=plugin.plugin_name, + key=key, + render_mode=render_mode, + cols=cols or {}, + attrs=attrs or {}, + elements=elements, + ) diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index 9dfc8b4da..c91844d11 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -11,6 +11,7 @@ ConfigWriter = Callable[[Any, Any], Any] AsyncConfigWriter = Callable[[Any, Any], Awaitable[Any]] ConfigDeleter = Callable[[Any], bool] PluginDataDeleter = Callable[[str], Any] +PluginExists = Callable[[str], bool] def _empty_read(_key: Any) -> Any: @@ -75,6 +76,69 @@ class PluginStorage: return self._delete_data(plugin_id) +class PluginConfigStore: + """封装插件配置键、存在性和强制删除规则。""" + + def __init__( + self, + *, + storage: Callable[[], "PluginStorage"], + plugin_exists: PluginExists, + key_prefix: str = "plugin.%s", + ) -> None: + """保存持久化端口和运行态插件查询端口。""" + self._storage = storage + self._plugin_exists = plugin_exists + self._key_prefix = key_prefix + + def _key(self, plugin_id: str) -> str: + """构造插件配置在统一配置存储中的键。""" + return self._key_prefix % plugin_id + + def read(self, plugin_id: str) -> dict: + """读取配置并过滤历史空键。""" + if not self._plugin_exists(plugin_id): + return {} + config = self._storage().read(self._key(plugin_id)) + return { + key: value + for key, value in (config or {}).items() + if key + } + + def write(self, plugin_id: str, config: dict, force: bool = False) -> bool: + """保存配置,默认拒绝不存在插件的配置写入。""" + if not force and not self._plugin_exists(plugin_id): + return False + self._storage().write(self._key(plugin_id), config) + return True + + async def async_write( + self, + plugin_id: str, + config: dict, + force: bool = False, + ) -> bool: + """异步保存配置并保持同步写入的存在性规则。""" + if not force and not self._plugin_exists(plugin_id): + return False + await self._storage().async_write(self._key(plugin_id), config) + return True + + def delete(self, plugin_id: str, force: bool = False) -> bool: + """删除配置并保持停止插件后的强制删除能力。""" + if not force and not self._plugin_exists(plugin_id): + return False + return self._storage().delete(self._key(plugin_id)) + + def delete_data(self, plugin_id: str, force: bool = False) -> bool: + """删除插件业务数据并保持旧的布尔结果合同。""" + if not force and not self._plugin_exists(plugin_id): + return False + self._storage().delete_data(plugin_id) + return True + + _plugin_storage = PluginStorage() diff --git a/app/runtime/extensions/plugin/sync.py b/app/runtime/extensions/plugin/sync.py new file mode 100644 index 000000000..bdc9f9857 --- /dev/null +++ b/app/runtime/extensions/plugin/sync.py @@ -0,0 +1,140 @@ +"""插件市场同步运行时用例。""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any, Callable, Optional + +from app.runtime.extensions.plugin.system import PluginSystemServices + + +class PluginSyncService: + """根据已安装清单同步缺失或过期插件,不参与插件实例生命周期。""" + + def __init__( + self, + *, + frozen: Callable[[], bool], + installed_plugins: Callable[[], list[str]], + online_plugins: Callable[[], list[Any]], + local_plugins: Callable[[], list[Any]], + merge_plugins: Callable[[list[Any], list[Any], list[Any]], list[Any]], + plugin_exists: Callable[[str, Optional[str]], bool], + install: Callable[[str, Optional[str], bool], tuple[bool, str]], + report: Callable[..., Any], + log: Any, + ) -> None: + """保存目录读取、包安装和持久化报告端口。""" + self._frozen = frozen + self._installed_plugins = installed_plugins + self._online_plugins = online_plugins + self._local_plugins = local_plugins + self._merge_plugins = merge_plugins + self._plugin_exists = plugin_exists + self._install = install + self._report = report + self._logger = log + + def sync(self) -> list[str]: + """并发安装本地缺失或需要更新的已安装插件。""" + if self._frozen(): + return [] + + installed = self._installed_plugins() + online = self._online_plugins() + local = self._local_plugins() + candidates = self._merge_plugins(online + local, [], []) if online or local else [] + targets = [ + plugin + for plugin in candidates + if plugin.id in installed + and plugin.system_version_compatible is not False + and not self._plugin_exists(plugin.id, plugin.plugin_version) + ] + if not targets: + return [] + + self._logger.info("开始安装第三方插件...") + synced: list[str] = [] + failed: list[str] = [] + + def install_one(plugin: Any) -> None: + """安装一个插件并记录结果。""" + started = time.time() + state, message = self._install(plugin.id, plugin.repo_url, True) + elapsed = time.time() - started + if state: + self._report(plugin_id=plugin.id, repo_url=plugin.repo_url) + self._logger.info( + f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version}," + f"耗时:{elapsed:.2f} 秒" + ) + synced.append(plugin.id) + else: + self._logger.error( + f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:" + f"{message},耗时:{elapsed:.2f} 秒" + ) + failed.append(plugin.id) + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = {executor.submit(install_one, plugin): plugin for plugin in targets} + for future in as_completed(futures): + plugin = futures[future] + try: + future.result() + except Exception as error: # noqa: BLE001 + self._logger.error( + f"插件 {plugin.plugin_name} 安装过程中出现异常: {error}" + ) + + self._logger.info( + f"第三方插件安装完成,成功:{len(synced)} 个,失败:{len(failed)} 个" + ) + return synced + + +class LocalPluginSyncService: + """同步本地插件仓源码到运行目录,并记录热重载抑制窗口。""" + + def __init__( + self, + *, + installed_plugins: Callable[[], list[str]], + candidate: Callable[[str], Optional[dict]], + system: Callable[[], PluginSystemServices], + recent_sync: dict[str, float], + log: Any, + ) -> None: + """保存本地候选、包同步和运行态监控端口。""" + self._installed_plugins = installed_plugins + self._candidate = candidate + self._system = system + self._recent_sync = recent_sync + self._logger = log + + def sync(self, plugin_id: str, candidate: Optional[dict] = None) -> bool: + """同步已安装且兼容的本地插件,成功后记录短时事件抑制标记。""" + if plugin_id not in self._installed_plugins(): + self._logger.info(f"本地插件 {plugin_id} 尚未安装,跳过自动同步和热重载") + return False + candidate = candidate or self._candidate(plugin_id) + if not candidate or candidate.get("compatible") is False: + if candidate: + self._logger.info( + f"本地插件 {plugin_id} 不满足同步条件,跳过同步:" + f"{candidate.get('skip_reason')}" + ) + return False + source_dir = Path(candidate.get("path")) + try: + if not self._system().package.sync_local(plugin_id, source_dir): + return False + self._recent_sync[plugin_id] = time.time() + self._logger.info(f"已同步本地插件 {plugin_id}:{source_dir}") + return True + except Exception as error: + self._logger.error(f"同步本地插件 {plugin_id} 失败:{error}") + return False diff --git a/app/runtime/extensions/plugin/tools.py b/app/runtime/extensions/plugin/tools.py new file mode 100644 index 000000000..5d441b654 --- /dev/null +++ b/app/runtime/extensions/plugin/tools.py @@ -0,0 +1,85 @@ +"""插件 Agent 工具目录缓存。""" + +from __future__ import annotations + +import threading +from typing import Any, Mapping, Optional + +from app.runtime.extensions.plugin.contracts import supports_plugin_hook + + +class PluginToolCatalog: + """按插件运行态版本构建并缓存 Agent 工具声明。""" + + def __init__(self, *, max_attempts: int = 3) -> None: + """创建空目录,并限制状态持续变化时的重试次数。""" + self._max_attempts = max_attempts + self._cache: dict[str, list[dict[str, Any]]] = {} + self._lock = threading.Lock() + self._revision = 0 + + @property + def revision(self) -> int: + """返回当前插件工具目录版本。""" + with self._lock: + return self._revision + + def clear(self) -> None: + """清空目录缓存并推进版本号。""" + with self._lock: + self._cache.clear() + self._revision += 1 + + def get( + self, + running_plugins: Mapping[str, Any], + *, + plugin_id: Optional[str] = None, + log: Any, + ) -> list[dict[str, Any]]: + """返回指定插件或全部运行插件的工具声明快照。""" + cache_key = plugin_id or "__all__" + for _attempt in range(self._max_attempts): + with self._lock: + cache_revision = self._revision + cached = self._cache.get(cache_key) + if cached is not None: + return self.copy(cached) + + tools_info = [] + for current_id, plugin in dict(running_plugins).items(): + if plugin_id and plugin_id != current_id: + continue + if not supports_plugin_hook(plugin, "get_agent_tools"): + continue + try: + if not plugin.get_state(): + continue + tools = plugin.get_agent_tools() + if tools: + tools_info.append({ + "plugin_id": current_id, + "plugin_name": plugin.plugin_name, + "tools": tools, + }) + except Exception as err: + log.error( + f"获取插件 {current_id} 智能体工具出错:{str(err)}" + ) + with self._lock: + if cache_revision != self._revision: + continue + self._cache[cache_key] = self.copy(tools_info) + return tools_info + raise RuntimeError("插件工具注册表持续变化,无法建立当前快照") + + @staticmethod + def copy(tools_info: list[dict[str, Any]]) -> list[dict[str, Any]]: + """复制工具注册信息,避免调用方修改缓存内容。""" + return [ + { + **plugin_info, + "tools": list(plugin_info.get("tools", [])), + } + for plugin_info in tools_info + ] diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 870288726..ba75d75ee 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -1,19 +1,8 @@ -import ast import asyncio -import importlib.util -import inspect -import os import posixpath -import sys -import threading -import time -import traceback -from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple -from fastapi import HTTPException -from starlette import status from watchfiles import watch from app.schemas.plugin import Plugin as _SchemaPlugin @@ -25,11 +14,28 @@ from app.runtime.log import logger from app.runtime.config import settings from app.runtime.events import EventHandlerBinding, eventmanager from app.runtime.reload import ConfigReloadMixin -from app.runtime.extensions.plugin.contracts import supports_plugin_hook +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.lifecycle import PluginLifecycle +from app.runtime.extensions.plugin.metadata import PluginMetadataMapper +from app.runtime.extensions.plugin.monitor import ( + PluginChangeMonitor, + PluginMonitorController, +) from app.runtime.extensions.plugin.projection import PluginProjection from app.runtime.extensions.plugin.registry import PluginRegistry from app.runtime.extensions.plugin.storage import get_plugin_storage from app.runtime.extensions.plugin.system import get_plugin_system +from app.runtime.extensions.plugin.tools import PluginToolCatalog +from app.runtime.extensions.plugin.sync import ( + LocalPluginSyncService, + PluginSyncService, +) +from app.runtime.extensions.plugin.clone import PluginCloneService +from app.runtime.extensions.plugin.access import PluginAccessPolicy +from app.runtime.extensions.plugin.catalog import PluginCatalogFacade +from app.runtime.extensions.plugin.paths import PluginPathResolver +from app.runtime.extensions.plugin.dependency import PluginDependencyService +from app.runtime.extensions.plugin.storage import PluginConfigStore from app.schemas.types import EventType, SystemConfigKey LegacyDiagnosticsConfigurator = Callable[..., None] @@ -120,16 +126,150 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._running_plugins = self._plugin_registry.running # 配置Key self._config_key: str = "plugin.%s" - # 监控线程 - self._monitor_thread: Optional[threading.Thread] = None - # 监控停止事件 - self._stop_monitor_event = threading.Event() + self._plugin_config_store = PluginConfigStore( + storage=lambda: get_plugin_storage(), + plugin_exists=lambda pid: bool(self._plugins.get(pid)), + key_prefix=self._config_key, + ) + self._plugin_access = PluginAccessPolicy( + auth_level=lambda: _site_auth_level_provider(), + verify_keys=RSAUtils.verify_rsa_keys, + log=logger, + ) + self._plugin_catalog_view = PluginCatalogFacade( + classes=lambda: self._plugins, + running=lambda: self._running_plugins, + storage=lambda: get_plugin_storage(), + system=get_plugin_system, + market_catalog=lambda: self._plugin_catalog(), + market_loader=lambda market, package_version=None, force=False: ( + self.get_plugins_from_market(market, package_version, force) + ), + async_market_loader=lambda market, package_version=None, force=False: ( + self.async_get_plugins_from_market(market, package_version, force) + ), + map_plugin=lambda **kwargs: self._process_plugin_info(**kwargs), + auth_checker=lambda **kwargs: self.__set_and_check_auth_level(**kwargs), + plugin_attr=lambda pid, attr: self.get_plugin_attr(pid, attr), + log=logger, + ) # 本地插件同步写入运行目录后的短时忽略窗口 self._recent_local_sync: Dict[str, float] = {} - # 插件智能体工具注册表缓存,插件启停或配置生效时主动失效。 - self._plugin_agent_tools_cache: Dict[str, List[Dict[str, Any]]] = {} - self._plugin_agent_tools_cache_lock = threading.Lock() - self._plugin_agent_tools_revision: int = 0 + self._plugin_paths = PluginPathResolver( + runtime_root=settings.ROOT_PATH / "app" / "plugins", + running=lambda: self._running_plugins, + system=get_plugin_system, + strict_system_version=lambda: not settings.DEV, + log=logger, + ) + self._local_plugin_sync = LocalPluginSyncService( + installed_plugins=lambda: get_plugin_storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + candidate=lambda pid: get_plugin_system().local_candidate(pid), + system=get_plugin_system, + recent_sync=self._recent_local_sync, + log=logger, + ) + self._plugin_monitor = PluginMonitorController( + runner=self._run_file_watcher, + log=logger, + ) + self._plugin_dependencies = PluginDependencyService( + system=get_plugin_system, + log=logger, + ) + self._plugin_loader = PluginLoader( + plugins_root=settings.ROOT_PATH / "app" / "plugins", + import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), + import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), + log=logger, + ) + self._plugin_tool_catalog = PluginToolCatalog( + max_attempts=self.AGENT_TOOLS_BUILD_MAX_ATTEMPTS + ) + self._plugin_lifecycle = PluginLifecycle( + classes=self._plugins, + running=self._running_plugins, + load_plugins=lambda pid, installed, check: self._load_selective_plugins( + pid, + installed, + check, + ), + installed_plugins=lambda: get_plugin_storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + plugin_config=self.get_plugin_config, + auth_checker=lambda plugin: self.__set_and_check_auth_level(plugin=plugin), + clear_modules=lambda pid: self._clear_plugin_modules(pid), + clear_tools=self.clear_plugin_agent_tools_cache, + enable_events=eventmanager.enable_event_handler, + disable_events=eventmanager.disable_event_handler, + log=logger, + event_sender=eventmanager.send_event, + ) + self._plugin_metadata = PluginMetadataMapper( + plugin_instance=self._plugin_registry.instance, + plugin_class=self._plugin_registry.plugin_class, + annotate_system_version=lambda info: get_plugin_system().annotate_system_version( + info + ), + is_package_compatible=lambda info, version: get_plugin_system().is_package_compatible( + info, + version, + ), + auth_checker=lambda plugin, source: self.__set_and_check_auth_level( + plugin, + source, + ), + version_compare=compare_version, + log=logger, + ) + self._plugin_sync = PluginSyncService( + frozen=lambda: get_plugin_system().is_frozen(), + installed_plugins=lambda: get_plugin_storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + online_plugins=lambda: self.get_online_plugins(), + local_plugins=lambda: self.get_local_repo_plugins(), + merge_plugins=lambda higher, base, _markets: self.process_plugins_list( + higher, + base, + ), + plugin_exists=lambda plugin_id, version: self.is_plugin_exists( + plugin_id, + version, + ), + install=lambda plugin_id, repo_url, force: get_plugin_system().package.install( + plugin_id=plugin_id, + repo_url=repo_url, + force_install=force, + ), + report=lambda **kwargs: _plugin_install_reporter(**kwargs), + log=logger, + ) + self._plugin_clone = PluginCloneService( + plugin_class=self._plugin_registry.plugin_class, + plugin_exists=lambda plugin_id: self.is_plugin_exists(plugin_id), + package_clone=lambda **kwargs: get_plugin_system().package.clone(**kwargs), + installed_plugins=lambda: get_plugin_storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + save_installed_plugins=lambda plugins: get_plugin_storage().write( + SystemConfigKey.UserInstalledPlugins, + plugins, + ), + read_config=self.get_plugin_config, + save_config=lambda plugin_id, config: self.save_plugin_config( + plugin_id, + config, + force=True, + ), + reload_plugin=self.reload_plugin, + running_plugin=self._plugin_registry.instance, + initialize_plugin=self.init_plugin, + log=logger, + ) # 事件总线只通过通用解析器访问运行中的插件实例。 eventmanager.register_handler_instance_resolver( "plugins", @@ -171,53 +311,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param pid: 插件ID,为空加载所有插件 """ - _legacy_diagnostics_configurator( - enabled=settings.DEBUG, - emitter=logger.warning, - ) - - def check_module(module: Any): - """ - 检查模块 - """ - if not hasattr(module, 'init_plugin') or not hasattr(module, "plugin_name"): - return False - return True - - # 已安装插件 - installed_plugins = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] - # 扫描插件目录,只加载符合条件的插件 - plugins = self._load_selective_plugins(pid, installed_plugins, check_module) - # 排序 - plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) - for plugin in plugins: - plugin_id = plugin.__name__ - if pid and plugin_id != pid: - continue - try: - # 判断插件是否满足认证要求,如不满足则不进行实例化 - if not self.__set_and_check_auth_level(plugin=plugin): - # 如果是插件热更新实例,这里则进行替换 - if plugin_id in self._plugins: - self._plugins[plugin_id] = plugin - continue - # 存储Class - self._plugins[plugin_id] = plugin - # 生成实例 - plugin_obj = plugin() - # 生效插件配置 - plugin_obj.init_plugin(self.get_plugin_config(plugin_id)) - # 存储运行实例 - self._running_plugins[plugin_id] = plugin_obj - logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}") - # 启用的插件才设置事件注册状态可用 - if plugin_obj.get_state(): - eventmanager.enable_event_handler(plugin) - else: - eventmanager.disable_event_handler(plugin) - except Exception as err: - logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}") - self.clear_plugin_agent_tools_cache() + _legacy_diagnostics_configurator(enabled=settings.DEBUG, emitter=logger.warning) + self._plugin_lifecycle.start(pid) def init_plugin(self, plugin_id: str, conf: dict): """ @@ -225,70 +320,31 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param plugin_id: 插件ID :param conf: 插件配置 """ - plugin = self._running_plugins.get(plugin_id) - if not plugin: - return - # 初始化插件 - plugin.init_plugin(conf) - # 检查插件状态并启用/禁用事件处理器 - if plugin.get_state(): - # 启用插件类的事件处理器 - eventmanager.enable_event_handler(type(plugin)) - else: - # 禁用插件类的事件处理器 - eventmanager.disable_event_handler(type(plugin)) - self.clear_plugin_agent_tools_cache() + self._plugin_lifecycle.initialize(plugin_id, conf) def clear_plugin_agent_tools_cache(self) -> None: """ 清空插件智能体工具注册表缓存。 """ - with self._plugin_agent_tools_cache_lock: - self._plugin_agent_tools_cache.clear() - self._plugin_agent_tools_revision += 1 + self._plugin_tool_catalog.clear() def get_plugin_agent_tools_revision(self) -> int: """ 获取插件智能体工具注册表版本号。 """ - with self._plugin_agent_tools_cache_lock: - return self._plugin_agent_tools_revision + return self._plugin_tool_catalog.revision + + @property + def _plugin_agent_tools_revision(self) -> int: + """兼容读取旧私有字段,实际版本由独立工具目录持有。""" + return self._plugin_tool_catalog.revision def stop(self, pid: Optional[str] = None): """ 停止插件服务 :param pid: 插件ID,为空停止所有插件 """ - # 停止插件 - if pid: - logger.info(f"正在停止插件 {pid}...") - plugin_obj = self._running_plugins.get(pid) - if not plugin_obj: - # 指定插件可能在上次加载时已导入模块但初始化失败,此时不会进入运行态列表。 - # 仍需继续清理类缓存和 sys.modules,避免后续热重载反复复用旧模块。 - logger.debug(f"插件 {pid} 不存在或未加载") - plugins = {} - else: - plugins = {pid: plugin_obj} - else: - logger.info("正在停止所有插件...") - plugins = self._running_plugins - for plugin_id, plugin in plugins.items(): - eventmanager.disable_event_handler(type(plugin)) - self.__stop_plugin(plugin) - # 清空对象 - if pid: - # 清空指定插件 - self._plugin_registry.remove(pid) - # 清除插件模块缓存,包括所有子模块 - self._clear_plugin_modules(pid) - else: - # 清空 - self._plugin_registry.clear() - # 清除所有插件模块缓存 - self._clear_plugin_modules() - self.clear_plugin_agent_tools_cache() - logger.info("插件停止完成") + self._plugin_lifecycle.stop(pid) @staticmethod def _load_selective_plugins(pid: Optional[str], installed_plugins: List[str], @@ -300,80 +356,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param check_module_func: 模块检查函数 :return: 插件类列表 """ - import importlib - - plugins = [] - plugins_dir = settings.ROOT_PATH / "app" / "plugins" - - if not plugins_dir.exists(): - logger.warning(f"插件目录不存在:{plugins_dir}") - return plugins - - # 确定需要加载的插件目录名称列表 - if pid: - # 加载指定插件 - target_plugins = [pid.lower()] - else: - # 加载已安装插件 - target_plugins = [plugin_id.lower() for plugin_id in installed_plugins] - - if not target_plugins: - logger.debug("没有需要加载的插件") - return plugins - - # 扫描plugins目录 - _loaded_modules = set() - for plugin_dir in plugins_dir.iterdir(): - if not plugin_dir.is_dir() or plugin_dir.name.startswith('_'): - continue - - # 检查是否是需要加载的插件 - if plugin_dir.name not in target_plugins: - logger.debug(f"跳过插件目录:{plugin_dir.name}(不在加载列表中)") - continue - - # 检查__init__.py是否存在 - init_file = plugin_dir / "__init__.py" - if not init_file.exists(): - logger.debug(f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)") - continue - - try: - # 构建模块名 - module_name = f"app.plugins.{plugin_dir.name}" - logger.debug(f"正在导入插件模块:{module_name}") - - # 旧插件可能直接导入带宿主资源前置条件的第三方包。资源必须在 - # Python 执行插件模块顶层代码前就绪,否则导入副作用无法安全回滚。 - _legacy_plugin_import_preparer( - plugin_id=plugin_dir.name, - plugin_dir=plugin_dir, - ) - - _legacy_import_scanner( - plugin_id=plugin_dir.name, - plugin_dir=plugin_dir, - ) - - # 导入模块 - module = importlib.import_module(module_name) - - # 检查模块中的类 - for name, obj in module.__dict__.items(): - if name.startswith('_') or not isinstance(obj, type): - continue - if name in _loaded_modules: - continue - if check_module_func(obj): - _loaded_modules.add(name) - plugins.append(obj) - logger.debug(f"找到符合条件的插件类:{name}") - break - - except Exception as err: - logger.error(f"加载插件 {plugin_dir.name} 失败:{str(err)} - {traceback.format_exc()}") - - return plugins + return PluginLoader( + plugins_root=settings.ROOT_PATH / "app" / "plugins", + import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), + import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), + log=logger, + ).load(pid, installed_plugins, check_module_func) @property def running_plugins(self) -> Dict[str, Any]: @@ -403,142 +391,39 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 重新加载插件文件修改监测 """ - if settings.DEV or settings.PLUGIN_AUTO_RELOAD: - # 先关闭已有监测,再重新启动 - self.stop_monitor() - self.__start_monitor() - else: - self.stop_monitor() + self._plugin_monitor.reload( + enabled=settings.DEV or settings.PLUGIN_AUTO_RELOAD + ) def __start_monitor(self): """ 启用监测插件文件修改监测 """ - if self._monitor_thread and self._monitor_thread.is_alive(): - logger.info("插件文件修改监测已经在运行中...") - return - - logger.info("开始监测插件文件修改...") - - # 在启动新线程之前,确保停止事件是清除状态 - self._stop_monitor_event.clear() - - # 创建并启动监控线程 - self._monitor_thread = threading.Thread( - target=self._run_file_watcher, - daemon=True - ) - self._monitor_thread.start() + self._plugin_monitor.start() def stop_monitor(self): """ 停止监测插件文件修改监测 """ - if self._monitor_thread and self._monitor_thread.is_alive(): - logger.info("正在停止插件文件修改监测...") - self._stop_monitor_event.set() - self._monitor_thread.join(timeout=5) - if self._monitor_thread.is_alive(): - logger.warning("插件文件修改监测线程在5秒内未能正常停止。") - self._monitor_thread = None - logger.info("插件文件修改监测停止完成") - else: - logger.info("未启用插件文件修改监测,无需停止") + self._plugin_monitor.stop() def _run_file_watcher(self): """ 运行 watchfiles 监视器的主循环。 """ - # 监视插件目录 - plugin_paths = [str(settings.ROOT_PATH / "app" / "plugins")] - for local_repo_path in get_plugin_system().local_repo_paths(): - if local_repo_path.exists() and local_repo_path.is_dir(): - plugin_paths.append(str(local_repo_path)) - logger.info(">>> 监控线程已启动,准备进入watch循环...") - # 使用 watchfiles 监视目录变化,并响应变化事件 - # Todo: yield_on_timeout = True 时,每秒检查停止事件,会返回空集合;后续可以考虑用来做心跳之类的功能? - for changes in watch(*plugin_paths, stop_event=self._stop_monitor_event, rust_timeout=1000, - yield_on_timeout=True): - # 如果收到停止事件,退出循环 - if not changes: - continue - - # 处理变化事件 - plugins_to_reload = set() - local_plugins_to_sync = {} - for _change_type, path_str in changes: - event_path = Path(path_str) - - # 跳过 pycache 目录中的文件 - if "__pycache__" in event_path.parts: - continue - - if event_path.name == "requirements.txt": - candidate = self._get_local_plugin_candidate_from_path(event_path) - if candidate: - if candidate.get("compatible") is False: - logger.info( - f"检测到本地插件 {candidate.get('id')} 依赖文件变化," - f"但跳过处理:{candidate.get('skip_reason')}" - ) - continue - logger.warn(f"检测到本地插件 {candidate.get('id')} 依赖文件变化,请重新安装本地插件以安装依赖") - continue - - federated_change = self._get_federated_plugin_change(event_path) - if federated_change: - pid, candidate, remote_entry_ready = federated_change - # 运行目录由构建方直接写入;外部本地仓库只在入口完整时同步运行副本。 - if candidate and remote_entry_ready: - if candidate.get("compatible") is False: - logger.info( - f"检测到本地插件 {pid} 联邦构建产物变化," - f"但跳过同步:{candidate.get('skip_reason')}" - ) - elif pid not in local_plugins_to_sync: - local_plugins_to_sync[pid] = (candidate, event_path, False) - continue - - # 跳过非 .py 文件 - if not event_path.name.endswith(".py"): - continue - - # 解析插件ID - runtime_pid = self._get_plugin_id_from_path(event_path) - local_candidate = self._get_local_plugin_candidate_from_path(event_path) if not runtime_pid else None - if runtime_pid: - last_sync_time = self._recent_local_sync.get(runtime_pid) - if last_sync_time and time.time() - last_sync_time < 2: - continue - # 运行目录变化只重载,不能反向触发本地同步。 - plugins_to_reload.add(runtime_pid) - elif local_candidate: - if local_candidate.get("compatible") is False: - package_version = local_candidate.get("package_version") - source_root = f"plugins.{package_version}" if package_version else "plugins" - logger.info( - f"检测到本地插件 {local_candidate.get('id')} 文件变化,来源:{source_root}," - f"文件:{event_path},但跳过同步:{local_candidate.get('skip_reason')}" - ) - continue - local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path, True) - - for pid, (candidate, event_path, should_reload) in local_plugins_to_sync.items(): - package_version = candidate.get("package_version") - source_root = f"plugins.{package_version}" if package_version else "plugins" - change_name = "Python 文件" if should_reload else "联邦构建产物" - logger.info(f"检测到本地插件 {pid} {change_name}变化,来源:{source_root},文件:{event_path}") - if self._sync_local_plugin_if_installed(pid, candidate) and should_reload: - plugins_to_reload.add(pid) - - # 触发重载 - if plugins_to_reload: - logger.info(f"检测到插件文件变化,准备重载: {list(plugins_to_reload)}") - for pid in plugins_to_reload: - try: - self.reload_plugin(pid) - except Exception as e: - logger.error(f"插件 {pid} 热重载失败: {e}", exc_info=True) + PluginChangeMonitor( + runtime_root=settings.ROOT_PATH / "app" / "plugins", + local_roots=get_plugin_system().local_repo_paths, + stop_event=self._plugin_monitor.stop_event, + recent_sync=self._recent_local_sync, + federated_change=self._get_federated_plugin_change, + runtime_plugin=self._get_plugin_id_from_path, + local_candidate=self._get_local_plugin_candidate_from_path, + sync_local=self._sync_local_plugin_if_installed, + reload_plugin=self.reload_plugin, + watch=watch, + log=logger, + ).run() def _get_federated_plugin_change( self, @@ -549,211 +434,41 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :return: 插件 ID、本地仓库候选和联邦入口是否完整;非联邦目录变化返回 None。 """ - try: - event_path = event_path.resolve() - candidate = self._get_local_plugin_candidate_from_path(event_path) - if candidate: - pid = candidate.get("id") - plugin_dir = Path(candidate.get("path")).resolve() - else: - runtime_root = (settings.ROOT_PATH / "app" / "plugins").resolve() - if not event_path.is_relative_to(runtime_root): - return None - relative_parts = event_path.relative_to(runtime_root).parts - if not relative_parts: - return None - plugin_dir = runtime_root / relative_parts[0] - pid = next( - ( - plugin_id - for plugin_id in self._running_plugins - if plugin_id.lower() == relative_parts[0].lower() - ), - None, - ) + return self._plugin_paths.federated_change(event_path) - if not pid: - return None - plugin = self._running_plugins.get(pid) - if not plugin: - return None - - render_mode, dist_path = plugin.get_render_mode() - if render_mode != "vue" or not isinstance(dist_path, str) or not dist_path: - return None - - relative_dist_path = Path(dist_path) - if relative_dist_path.is_absolute() or ".." in relative_dist_path.parts or "\\" in dist_path: - return None - - plugin_dir = plugin_dir.resolve() - dist_dir = (plugin_dir / relative_dist_path).resolve() - if ( - dist_dir == plugin_dir - or not dist_dir.is_relative_to(plugin_dir) - or not event_path.is_relative_to(dist_dir) - ): - return None - - remote_entry = dist_dir / "remoteEntry.js" - remote_entry_ready = ( - remote_entry.is_file() - and remote_entry.resolve().is_relative_to(plugin_dir) - ) - return pid, candidate, remote_entry_ready - except Exception as e: - logger.error(f"识别插件联邦构建产物变化时出错: {e}") - return None - - @staticmethod - def _get_plugin_id_from_path(event_path: Path) -> Optional[str]: + def _get_plugin_id_from_path(self, event_path: Path) -> Optional[str]: """ 根据文件路径解析出插件的ID。 :param event_path: 被修改文件的 Path 对象。 :return: 插件ID字符串,如果不是有效插件文件则返回 None。 """ - try: - event_path = event_path.resolve() - plugins_root = settings.ROOT_PATH / "app" / "plugins" - # 确保修改的文件在 plugins 目录下 - if not event_path.is_relative_to(plugins_root): - return None + return self._plugin_paths.runtime_plugin(event_path) - try: - plugin_dir_name = event_path.relative_to(plugins_root).parts[0] - plugin_dir = plugins_root / plugin_dir_name - except (ValueError, IndexError): - return None - - init_file = plugin_dir / "__init__.py" - if not init_file.exists(): - return None - - # 读取 __init__.py 文件,查找插件主类名 - with open(init_file, "r", encoding="utf-8", errors="replace") as f: - source_code = f.read() - - tree = ast.parse(source_code) - - # 遍历AST,查找继承自 _PluginBase 的类 - for node in ast.walk(tree): - # 检查节点是否为类定义 - if isinstance(node, ast.ClassDef): - # 遍历该类的所有基类 - for base in node.bases: - # 检查基类是否是我们寻找的 _PluginBase - # ast.Name 用于处理简单的基类名 - if isinstance(base, ast.Name) and base.id == '_PluginBase': - # 返回这个类的名字 - return node.name - - return None - except Exception as e: - logger.error(f"从路径解析插件ID时出错: {e}") - return None - - @staticmethod - def _get_local_plugin_candidate_from_path(event_path: Path) -> Optional[dict]: + def _get_local_plugin_candidate_from_path(self, event_path: Path) -> Optional[dict]: """ 根据本地插件仓库路径解析具体插件候选,保留 plugins/plugins.v2 来源差异 """ - try: - event_path = event_path.resolve() - for local_repo_path in get_plugin_system().local_repo_paths(): - if not local_repo_path.exists() or not local_repo_path.is_dir(): - continue - if not event_path.is_relative_to(local_repo_path): - continue - try: - relative_parts = event_path.relative_to(local_repo_path).parts - except (ValueError, IndexError): - continue - if len(relative_parts) < 2: - continue - if relative_parts[0] == "plugins": - package_version = "" - elif relative_parts[0].startswith("plugins."): - package_version = relative_parts[0].split(".", 1)[1] - else: - continue - plugin_dir_name = relative_parts[1] - candidate = get_plugin_system().local_candidate( - plugin_dir_name, - package_version=package_version, - repo_path=local_repo_path, - strict_compat=False, - strict_system_version=not settings.DEV, - ) - if candidate: - return candidate - return None - except Exception as e: - logger.error(f"从本地插件仓库路径解析插件候选时出错: {e}") - return None + return self._plugin_paths.local_candidate(event_path) - @staticmethod - def _sync_local_plugin_if_installed(pid: str, candidate: Optional[dict] = None) -> bool: + def _sync_local_plugin_if_installed(self, pid: str, candidate: Optional[dict] = None) -> bool: """ 已安装本地插件源码变化时,同步到运行目录 """ - installed_plugins = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] - if pid not in installed_plugins: - logger.info(f"本地插件 {pid} 尚未安装,跳过自动同步和热重载") - return False - - candidate = candidate or get_plugin_system().local_candidate(pid) - if not candidate: - return False - if candidate.get("compatible") is False: - logger.info(f"本地插件 {pid} 不满足同步条件,跳过自动同步:{candidate.get('skip_reason')}") - return False - - source_dir = Path(candidate.get("path")) - dest_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower() - try: - if not get_plugin_system().package.sync_local(pid, source_dir): - return False - PluginManager()._recent_local_sync[pid] = time.time() - logger.info(f"已同步本地插件 {pid}:{source_dir} -> {dest_dir}") - return True - except Exception as e: - logger.error(f"同步本地插件 {pid} 失败:{e}") - return False - - @staticmethod - def __stop_plugin(plugin: Any): - """ - 停止插件 - :param plugin: 插件实例 - """ - try: - # 关闭数据库 - if hasattr(plugin, "close"): - plugin.close() - # 关闭插件 - if hasattr(plugin, "stop_service"): - plugin.stop_service() - except Exception as e: - logger.warn(f"停止插件 {plugin.get_name()} 时发生错误: {str(e)}") + return self._local_plugin_sync.sync(pid, candidate) def remove_plugin(self, plugin_id: str): """ 从内存中移除一个插件 :param plugin_id: 插件ID """ - self.stop(plugin_id) + self._plugin_lifecycle.stop(plugin_id) def reload_plugin(self, plugin_id: str): """ 将一个插件重新加载到内存 :param plugin_id: 插件ID """ - # 先移除插件实例 - self.stop(plugin_id) - # 重新加载 - self.start(plugin_id) - # 广播事件 - eventmanager.send_event(EventType.PluginReload, data={"plugin_id": plugin_id}) + self._plugin_lifecycle.reload(plugin_id, EventType.PluginReload) @staticmethod def _clear_plugin_modules(plugin_id: Optional[str] = None): @@ -762,141 +477,36 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param plugin_id: 插件ID """ - # 构建插件模块前缀 - if plugin_id: - plugin_module_prefix = f"app.plugins.{plugin_id.lower()}" - else: - plugin_module_prefix = "app.plugins" - - # 收集需要删除的模块名(创建模块名列表的副本以避免迭代时修改字典) - modules_to_remove = [] - for module_name in list(sys.modules.keys()): - if module_name == plugin_module_prefix or module_name.startswith(plugin_module_prefix + "."): - modules_to_remove.append(module_name) - - # 删除模块 - for module_name in modules_to_remove: - try: - del sys.modules[module_name] - logger.debug(f"已清除插件模块缓存:{module_name}") - except KeyError: - # 模块可能已经被删除 - pass - - importlib.invalidate_caches() - logger.debug("已清除查找器的缓存") - - if plugin_id: - if modules_to_remove: - logger.info(f"插件 {plugin_id} 共清除 {len(modules_to_remove)} 个模块缓存:{modules_to_remove}") - else: - logger.debug(f"插件 {plugin_id} 没有找到需要清除的模块缓存") + return PluginLoader( + plugins_root=settings.ROOT_PATH / "app" / "plugins", + import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), + import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), + log=logger, + ).clear_modules(plugin_id) def sync(self) -> List[str]: """ 安装本地不存在或需要更新的插件 """ - def install_plugin(plugin): - start_time = time.time() - state, msg = get_plugin_system().package.install( - plugin_id=plugin.id, - repo_url=plugin.repo_url, - force_install=True, - ) - elapsed_time = time.time() - start_time - if state: - _plugin_install_reporter( - plugin_id=plugin.id, - repo_url=plugin.repo_url, - ) - logger.info( - f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version},耗时:{elapsed_time:.2f} 秒") - sync_plugins.append(plugin.id) - else: - logger.error( - f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg},耗时:{elapsed_time:.2f} 秒") - failed_plugins.append(plugin.id) - - if get_plugin_system().is_frozen(): - return [] - - # 获取已安装插件列表 - install_plugins = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] - # 获取远程和本地仓库来源插件列表 - online_plugins = self.get_online_plugins() - local_repo_plugins = self.get_local_repo_plugins() - candidate_plugins = self.process_plugins_list(online_plugins + local_repo_plugins, []) \ - if online_plugins or local_repo_plugins else [] - # 确定需要安装的插件 - plugins_to_install = [ - plugin for plugin in candidate_plugins - if plugin.id in install_plugins - and plugin.system_version_compatible is not False - and not self.is_plugin_exists(plugin.id, plugin.plugin_version) - ] - - if not plugins_to_install: - return [] - logger.info("开始安装第三方插件...") - sync_plugins = [] - failed_plugins = [] - - # 使用 ThreadPoolExecutor 进行并发安装 - total_start_time = time.time() - with ThreadPoolExecutor(max_workers=5) as executor: - futures = { - executor.submit(install_plugin, plugin): plugin - for plugin in plugins_to_install - } - for future in as_completed(futures): - plugin = futures[future] - try: - future.result() - except Exception as exc: - logger.error(f"插件 {plugin.plugin_name} 安装过程中出现异常: {exc}") - - total_elapsed_time = time.time() - total_start_time - logger.info( - f"第三方插件安装完成,成功:{len(sync_plugins)} 个," - f"失败:{len(failed_plugins)} 个,总耗时:{total_elapsed_time:.2f} 秒" - ) - return sync_plugins + return self._plugin_sync.sync() @staticmethod def install_plugin_missing_dependencies() -> List[str]: """ 安装插件中缺失或不兼容的依赖项 """ - dependency_installer = get_plugin_system().dependency - # 第一步:获取需要安装的依赖项列表 - missing_dependencies = dependency_installer.find_missing() - if not missing_dependencies: - return missing_dependencies - logger.debug(f"检测到缺失的依赖项: {missing_dependencies}") - logger.info(f"开始安装缺失的依赖项,共 {len(missing_dependencies)} 个...") - # 第二步:安装依赖项并返回结果 - total_start_time = time.time() - success, message = dependency_installer.install(missing_dependencies) - total_elapsed_time = time.time() - total_start_time - if success: - logger.info(f"已完成 {len(missing_dependencies)} 个依赖项安装,总耗时:{total_elapsed_time:.2f} 秒") - else: - logger.warning(f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{total_elapsed_time:.2f} 秒") - return missing_dependencies + return PluginDependencyService( + system=get_plugin_system, + log=logger, + ).install_missing() def get_plugin_config(self, pid: str) -> dict: """ 获取插件配置 :param pid: 插件ID """ - if not self._plugins.get(pid): - return {} - conf = get_plugin_storage().read(self._config_key % pid) - if conf: - # 去掉空Key - return {k: v for k, v in conf.items() if k} - return {} + return self._plugin_config_store.read(pid) def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool: """ @@ -905,10 +515,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param conf: 配置 :param force: 强制保存 """ - if not force and not self._plugins.get(pid): - return False - get_plugin_storage().write(self._config_key % pid, conf) - return True + return self._plugin_config_store.write(pid, conf, force) async def async_save_plugin_config( self, pid: str, conf: dict, force: bool = False @@ -919,10 +526,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param conf: 配置 :param force: 强制保存 """ - if not force and not self._plugins.get(pid): - return False - await get_plugin_storage().async_write(self._config_key % pid, conf) - return True + return await self._plugin_config_store.async_write(pid, conf, force) def delete_plugin_config(self, pid: str, force: bool = False) -> bool: """ @@ -930,9 +534,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param pid: 插件ID :param force: 插件停止后仍允许按插件 ID 删除持久化配置 """ - if not force and not self._plugins.get(pid): - return False - return get_plugin_storage().delete(self._config_key % pid) + return self._plugin_config_store.delete(pid, force) def delete_plugin_data(self, pid: str, force: bool = False) -> bool: """ @@ -940,10 +542,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param pid: 插件ID :param force: 插件停止后仍允许按插件 ID 删除持久化数据 """ - if not force and not self._plugins.get(pid): - return False - get_plugin_storage().delete_data(pid) - return True + return self._plugin_config_store.delete_data(pid, force) def get_plugin_state(self, pid: str) -> bool: """ @@ -1036,13 +635,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 复制插件智能体工具注册信息,避免调用方修改缓存内容。 """ - return [ - { - **plugin_info, - "tools": list(plugin_info.get("tools", [])), - } - for plugin_info in tools_info - ] + return PluginToolCatalog.copy(tools_info) def get_plugin_agent_tools(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ @@ -1053,42 +646,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): "tools": [ToolClass1, ToolClass2, ...] }] """ - cache_key = pid or "__all__" - for _attempt in range(self.AGENT_TOOLS_BUILD_MAX_ATTEMPTS): - with self._plugin_agent_tools_cache_lock: - cache_revision = self._plugin_agent_tools_revision - cached_tools = self._plugin_agent_tools_cache.get(cache_key) - if cached_tools is not None: - return self._copy_plugin_agent_tools(cached_tools) - - ret_tools = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if supports_plugin_hook(plugin, "get_agent_tools"): - try: - if not plugin.get_state(): - continue - tools = plugin.get_agent_tools() - if tools: - ret_tools.append({ - "plugin_id": plugin_id, - "plugin_name": plugin.plugin_name, - "tools": tools - }) - except Exception as e: - logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}") - with self._plugin_agent_tools_cache_lock: - if cache_revision != self._plugin_agent_tools_revision: - # 插件状态在注册表构建期间发生变化,重新读取以避免写回过期快照。 - continue - self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools( - ret_tools - ) - return ret_tools - raise RuntimeError("插件工具注册表持续变化,无法建立当前快照") + return self._plugin_tool_catalog.get( + self._running_plugins, + plugin_id=pid, + log=logger, + ) @staticmethod def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str: @@ -1140,51 +702,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 获取插件仪表盘 """ - - def __get_params_count(func: Callable): - """ - 获取函数的参数信息 - """ - signature = inspect.signature(func) - return len(signature.parameters) - - # 获取插件实例 - plugin_instance = self._plugin_registry.instance(pid) - if not plugin_instance: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载") - - # 渲染模式 - render_mode, _ = plugin_instance.get_render_mode() - # 获取插件仪表板 - try: - # 检查方法的参数个数 - params_count = __get_params_count(plugin_instance.get_dashboard) - if params_count > 1: - dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent) - elif params_count > 0: - dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent) - else: - dashboard: Tuple = plugin_instance.get_dashboard() - except Exception as e: - logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}") - if dashboard is None: - return None - if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3: - logger.error(f"插件 {pid} 返回的仪表盘数据格式错误") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"插件 {pid} 返回的仪表盘数据格式错误") - cols, attrs, elements = dashboard - return _SchemaPluginDashboard( - id=pid, - name=plugin_instance.plugin_name, - key=key, - render_mode=render_mode, - cols=cols or {}, - attrs=attrs or {}, - elements=elements - ) + return self._plugin_projection().dashboard(pid, key, user_agent) def get_plugin_attr(self, pid: str, attr: str) -> Any: """ @@ -1249,90 +767,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 获取所有在线插件信息 """ - if not settings.PLUGIN_MARKET: - return [] - compatible_flags = get_plugin_system().compatible_flags( - settings.VERSION_FLAG - ) - markets = [m for m in settings.PLUGIN_MARKET.split(",") if m] - result = self._plugin_catalog().collect( - markets=markets, - compatible_flags=compatible_flags, - force=force, - loader=self.get_plugins_from_market, - ) - logger.info(f"获取到 {len(result)} 个线上插件") - return result + return self._plugin_catalog_view.online(force) def get_local_plugins(self) -> List[_SchemaPlugin]: """ 获取所有本地已下载的插件信息 """ - # 返回值 - plugins = [] - # 已安装插件 - installed_apps = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] - for pid, plugin_class in self._plugins.items(): - # 运行状插件 - plugin_obj = self._running_plugins.get(pid) - # 基本属性 - plugin = _SchemaPlugin() - # ID - plugin.id = pid - # 安装状态 - if pid in installed_apps: - plugin.installed = True - else: - plugin.installed = False - # 运行状态 - if plugin_obj and hasattr(plugin_obj, "get_state"): - try: - state = plugin_obj.get_state() - except Exception as e: - logger.error(f"获取插件 {pid} 状态出错:{str(e)}") - state = False - plugin.state = state - else: - plugin.state = False - # 是否有详情页面 - if hasattr(plugin_class, "get_page"): - plugin.has_page = supports_plugin_hook(plugin_class, "get_page") - # 公钥 - if hasattr(plugin_class, "plugin_public_key"): - plugin.plugin_public_key = plugin_class.plugin_public_key - # 权限 - if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class): - continue - # 名称 - if hasattr(plugin_class, "plugin_name"): - plugin.plugin_name = plugin_class.plugin_name - # 描述 - if hasattr(plugin_class, "plugin_desc"): - plugin.plugin_desc = plugin_class.plugin_desc - # 版本 - if hasattr(plugin_class, "plugin_version"): - plugin.plugin_version = plugin_class.plugin_version - # 图标 - if hasattr(plugin_class, "plugin_icon"): - plugin.plugin_icon = plugin_class.plugin_icon - # 作者 - if hasattr(plugin_class, "plugin_author"): - plugin.plugin_author = plugin_class.plugin_author - # 作者链接 - if hasattr(plugin_class, "author_url"): - plugin.author_url = plugin_class.author_url - # 加载顺序 - if hasattr(plugin_class, "plugin_order"): - plugin.plugin_order = plugin_class.plugin_order - # 是否需要更新 - plugin.has_update = False - # 本地标志 - plugin.is_local = True - # 汇总 - plugins.append(plugin) - # 根据加载排序重新排序 - plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) - return plugins + return self._plugin_catalog_view.local() def get_local_plugin_version(self, pid: str) -> Optional[str]: """ @@ -1340,78 +781,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): 插件类由运行期动态加载,旧插件可能未声明版本属性,因此缺失时返回 None。 """ - installed_apps = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] - if pid not in installed_apps: - return None - # 保留测试和旧扩展可能替换 `_plugins` 字典的兼容接缝。 - plugin_class = self._plugins.get(pid) - if not plugin_class: - return None - return getattr(plugin_class, "plugin_version", None) + return self._plugin_catalog_view.local_version(pid) def get_local_repo_plugins(self) -> List[_SchemaPlugin]: """ 获取本地插件仓库目录中的插件信息 """ - plugins = [] - installed_apps = get_plugin_storage().read(SystemConfigKey.UserInstalledPlugins) or [] - local_candidates = get_plugin_system().local_candidates() - if not local_candidates: - return [] - for pid, plugin_info in local_candidates.items(): - package_version = plugin_info.get("package_version") - plugin = self._process_plugin_info( - pid=pid, - plugin_info=plugin_info, - market=get_plugin_system().local_repo_url( - pid, - plugin_info.get("repo_path"), - package_version - ), - installed_apps=installed_apps, - add_time=0, - package_version=package_version - ) - if not plugin: - continue - plugin.is_local = True - plugins.append(plugin) + return self._plugin_catalog_view.local_repository() - plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) - logger.info(f"获取到 {len(plugins)} 个本地插件") - return plugins - - @staticmethod - def is_plugin_exists(pid: str, version: str = None) -> bool: + def is_plugin_exists(self, pid: str, version: str = None) -> bool: """ 判断插件是否存在,并满足版本要求(有传入version时) :param pid: 插件ID :param version: 插件版本 """ - if not pid: - return False - try: - # 构建包名 - package_name = f"app.plugins.{pid.lower()}" - # 检查包是否存在 - spec = importlib.util.find_spec(package_name) - package_exists = spec is not None and spec.origin is not None - logger.debug(f"{pid} exists: {package_exists}") - if not package_exists: - return False - - local_version = PluginManager().get_plugin_attr(pid=pid, attr="plugin_version") - if not local_version: - return False - - if version and not compare_version(local_version, ">=", version): - logger.warn(f"Plugin {pid} version: {local_version} (older than version: {version})") - return False - - return True - except Exception as e: - logger.debug(f"获取插件是否在本地包中存在失败,{e}") - return False + return self._plugin_catalog_view.exists(pid, version) def get_plugins_from_market(self, market: str, package_version: Optional[str] = None, @@ -1423,7 +807,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param force: 是否强制刷新(忽略缓存) :return: 返回插件的列表,若获取失败返回 [] """ - return self._plugin_catalog().load(market, package_version, force) + return self._plugin_catalog_view.get_from_market( + market, + package_version, + force, + ) def process_plugins_list(self, higher_version_plugins: List[_SchemaPlugin], base_version_plugins: List[_SchemaPlugin]) -> List[_SchemaPlugin]: @@ -1433,11 +821,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param base_version_plugins: 基础版本插件列表 :return: 处理后的插件列表 """ - markets = [item for item in settings.PLUGIN_MARKET.split(",") if item] - return self._plugin_catalog().merge( + return self._plugin_catalog_view.merge( higher_version_plugins, base_version_plugins, - markets, ) def _process_plugin_info(self, pid: str, plugin_info: dict, market: str, @@ -1453,94 +839,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param package_version: 包版本 :return: 创建的插件对象,如果验证失败返回None """ - if not isinstance(plugin_info, dict): - return None - - plugin_info = get_plugin_system().annotate_system_version( - plugin_info.copy() + return self._plugin_metadata.map( + plugin_id=pid, + plugin_info=plugin_info, + market=market, + installed_plugins=installed_apps, + add_time=add_time, + package_version=package_version, ) - if not get_plugin_system().is_package_compatible( - plugin_info, package_version or "" - ): - # 插件当前版本不兼容 - return None - - # 运行状插件 - plugin_obj = self._plugin_registry.instance(pid) - # 非运行态插件 - plugin_static = self._plugin_registry.plugin_class(pid) - # 基本属性 - plugin = _SchemaPlugin() - # ID - plugin.id = pid - # 安装状态 - if pid in installed_apps and plugin_static: - plugin.installed = True - else: - plugin.installed = False - # 是否有新版本 - plugin.has_update = False - if plugin_static: - installed_version = getattr(plugin_static, "plugin_version") - if compare_version(installed_version, "<", plugin_info.get("version")): - # 需要更新 - plugin.has_update = True - # 主系统版本兼容性 - if plugin_info.get("system_version"): - plugin.system_version = plugin_info.get("system_version") - if plugin_info.get("system_version_compatible") is False: - plugin.system_version_compatible = False - plugin.system_version_message = plugin_info.get("system_version_message") - # 运行状态 - if plugin_obj and hasattr(plugin_obj, "get_state"): - try: - state = plugin_obj.get_state() - except Exception as e: - logger.error(f"获取插件 {pid} 状态出错:{str(e)}") - state = False - plugin.state = state - else: - plugin.state = False - # 是否有详情页面 - plugin.has_page = False - if plugin_obj and supports_plugin_hook(plugin_obj, "get_page"): - plugin.has_page = True - # 公钥 - if plugin_info.get("key"): - plugin.plugin_public_key = plugin_info.get("key") - # 权限 - if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info): - return None - # 名称 - if plugin_info.get("name"): - plugin.plugin_name = plugin_info.get("name") - # 描述 - if plugin_info.get("description"): - plugin.plugin_desc = plugin_info.get("description") - # 版本 - if plugin_info.get("version"): - plugin.plugin_version = plugin_info.get("version") - # 图标 - if plugin_info.get("icon"): - plugin.plugin_icon = plugin_info.get("icon") - # 标签 - plugin.plugin_label = self._normalize_plugin_label(plugin_info.get("labels")) - # 作者 - if plugin_info.get("author"): - plugin.plugin_author = plugin_info.get("author") - # 更新历史 - if plugin_info.get("history"): - plugin.history = plugin_info.get("history") - # Release 能力位来自插件市场索引,用于前端展示和后端安装入口双重校验。 - plugin.release = bool(plugin_info.get("release")) - # 仓库链接 - plugin.repo_url = market - # 本地标志 - plugin.is_local = False - # 添加顺序 - plugin.add_time = add_time - - return plugin @staticmethod def _normalize_plugin_label(labels: Any) -> Optional[str]: @@ -1550,13 +856,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param labels: 插件市场 package 中的 labels 字段 :return: 用空格拼接后的标签字符串,无法识别或为空时返回 None """ - if isinstance(labels, str): - label = labels.strip() - return label or None - if isinstance(labels, list): - normalized_labels = [str(item).strip() for item in labels if str(item).strip()] - return " ".join(normalized_labels) or None - return None + return PluginMetadataMapper.normalize_label(labels) async def async_get_online_plugins( self, @@ -1568,22 +868,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param force: 是否强制刷新(忽略缓存) :param progress_callback: 定时服务进度更新回调 """ - if not settings.PLUGIN_MARKET: - if progress_callback: - progress_callback(value=100, text="未配置插件市场,跳过刷新") - return [] - compatible_flags = get_plugin_system().compatible_flags( - settings.VERSION_FLAG + return await self._plugin_catalog_view.async_online( + force, + progress_callback, ) - result = await self._plugin_catalog().async_collect( - markets=[item for item in settings.PLUGIN_MARKET.split(",") if item], - compatible_flags=compatible_flags, - force=force, - loader=self.async_get_plugins_from_market, - progress_callback=progress_callback, - ) - logger.info(f"获取到 {len(result)} 个线上插件") - return result async def async_get_plugins_from_market(self, market: str, package_version: Optional[str] = None, @@ -1595,49 +883,24 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param force: 是否强制刷新(忽略缓存) :return: 返回插件的列表,若获取失败返回 [] """ - return await self._plugin_catalog().async_load( + return await self._plugin_catalog_view.async_get_from_market( market, package_version, force, ) - @staticmethod - def __set_and_check_auth_level(plugin: Union[_SchemaPlugin, Type[Any]], - source: Optional[Union[dict, Type[Any]]] = None) -> bool: + def __set_and_check_auth_level( + self, + plugin: Union[_SchemaPlugin, Type[Any]], + source: Optional[Union[dict, Type[Any]]] = None, + ) -> bool: """ 设置并检查插件的认证级别 :param plugin: 插件对象或包含 auth_level 属性的对象 :param source: 可选的字典对象或类对象,可能包含 "level" 或 "auth_level" 键 :return: 如果插件的认证级别有效且当前环境的认证级别满足要求,返回 True,否则返回 False """ - # 检查并赋值 source 中的 level 或 auth_level - if source: - if isinstance(source, dict) and "level" in source: - plugin.auth_level = source.get("level") - elif hasattr(source, "auth_level"): - plugin.auth_level = source.auth_level - # 如果 source 为空且 plugin 本身没有 auth_level,直接返回 True - elif not hasattr(plugin, "auth_level"): - return True - - # auth_level 级别说明 - # 1 - 所有用户可见 - # 2 - 站点认证用户可见 - # 3 - 站点&密钥认证可见 - # 99 - 站点&特殊密钥认证可见 - # 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证 - auth_level = _site_auth_level_provider() - if auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"): - plugin_id = plugin.id if isinstance(plugin, _SchemaPlugin) else plugin.__name__ - public_key = plugin.plugin_public_key - if public_key: - private_key = PluginManager.__get_plugin_private_key(plugin_id) - verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key) - return verify - # 如果当前站点认证级别小于插件级别,则返回 False - if auth_level < plugin.auth_level: - return False - return True + return self._plugin_access.check(plugin, source) @staticmethod def __get_plugin_private_key(plugin_id: str) -> Optional[str]: @@ -1646,14 +909,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param plugin_id: 插件标识 :return: 对应的插件私钥,如果未找到则返回 None """ - try: - # 将插件标识转换为大写并构建环境变量名称 - env_var_name = f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY" - private_key = os.environ.get(env_var_name) - return private_key - except Exception as e: - logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}") - return None + return PluginAccessPolicy.private_key(plugin_id) def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: @@ -1667,79 +923,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): :param icon: 自定义图标URL :return: (是否成功, 错误信息) """ - try: - # 验证参数 - if not plugin_id or not suffix: - return False, "插件ID和分身后缀不能为空" - - # 检查原插件是否存在 - if plugin_id not in self._plugins: - return False, f"原插件 {plugin_id} 不存在" - - # 生成分身插件ID - clone_id = f"{plugin_id}{suffix.lower()}" - - # 检查分身插件是否已存在 - if self.is_plugin_exists(clone_id): - return False, f"分身插件 {clone_id} 已存在" - - original_plugin_class = self._plugins.get(plugin_id) - if not original_plugin_class: - return False, f"无法获取原插件类 {plugin_id}" - - success, msg = get_plugin_system().package.clone( - plugin_id=plugin_id, - clone_id=clone_id, - original_class_name=original_plugin_class.__name__, - suffix=suffix.lower(), - name=name, - description=description, - version=version, - icon=icon, - ) - if not success: - return False, msg - - # 将分身插件添加到已安装列表 - storage = get_plugin_storage() - installed_plugins = storage.read(SystemConfigKey.UserInstalledPlugins) or [] - if clone_id not in installed_plugins: - installed_plugins.append(clone_id) - storage.write(SystemConfigKey.UserInstalledPlugins, installed_plugins) - - # 为分身插件创建初始配置(从原插件复制配置) - logger.info(f"正在为分身插件 {clone_id} 创建初始配置...") - original_config = self.get_plugin_config(plugin_id) - if original_config: - # 复制原插件配置作为分身插件的初始配置 - clone_config = original_config.copy() - # 可以在这里修改一些默认值,比如禁用分身插件 - # 默认禁用分身插件,让用户手动配置 - clone_config['enable'] = False - clone_config['enabled'] = False - self.save_plugin_config(clone_id, clone_config, force=True) - logger.info(f"已为分身插件 {clone_id} 设置初始配置") - else: - logger.info(f"原插件 {plugin_id} 没有配置,分身插件 {clone_id} 将使用默认配置") - - # 注册分身插件的API和服务 - logger.info(f"正在注册分身插件 {clone_id} ...") - PluginManager().reload_plugin(clone_id) - # 确保分身插件正确初始化配置 - if clone_id in self._running_plugins: - clone_instance = self._running_plugins[clone_id] - clone_config = self.get_plugin_config(clone_id) - if clone_config: - logger.info(f"正在为分身插件 {clone_id} 重新初始化配置...") - clone_instance.init_plugin(clone_config) - logger.info(f"分身插件 {clone_id} 配置重新初始化完成") - - logger.info(f"插件分身 {clone_id} 创建成功") - return True, clone_id - - except Exception as e: - logger.error(f"创建插件分身失败:{str(e)}") - return False, f"创建插件分身失败:{str(e)}" + return self._plugin_clone.clone( + plugin_id=plugin_id, + suffix=suffix, + name=name, + description=description, + version=version, + icon=icon, + ) def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str, name: str, description: str, version: str = None, diff --git a/app/runtime/extensions/service_config.py b/app/runtime/extensions/service_config.py index c752e93f3..88a257cbe 100644 --- a/app/runtime/extensions/service_config.py +++ b/app/runtime/extensions/service_config.py @@ -22,10 +22,12 @@ def _empty_service_config(_config_key: SystemConfigKey) -> Any: _service_config_reader: ServiceConfigReader = _empty_service_config -def configure_service_config_reader(reader: ServiceConfigReader) -> None: - """由启动组合根注入服务配置读取能力。""" +def configure_service_config_reader(reader: ServiceConfigReader) -> ServiceConfigReader: + """注入服务配置读取能力,并返回先前 reader 供隔离环境恢复。""" global _service_config_reader + previous = _service_config_reader _service_config_reader = reader + return previous class ServiceConfigHelper: diff --git a/app/runtime/extensions/service_registry.py b/app/runtime/extensions/service_registry.py deleted file mode 100644 index 455fc0f54..000000000 --- a/app/runtime/extensions/service_registry.py +++ /dev/null @@ -1,109 +0,0 @@ -from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator - -from app.runtime.extensions.module_manager import ModuleManager -from app.runtime.extensions.service_config import ServiceConfigHelper -from app.schemas.system import ServiceInfo -from app.schemas.types import SystemConfigKey, ModuleType - -TConf = TypeVar("TConf") - -__all__ = [ - "ServiceBaseHelper", - "ServiceConfigHelper", -] - - -class ServiceBaseHelper(Generic[TConf]): - """ - 通用服务帮助类,抽象获取配置和服务实例的通用逻辑 - """ - - def __init__(self, config_key: SystemConfigKey, conf_type: Type[TConf], module_type: ModuleType): - """绑定服务配置类型与对应的运行模块类型。""" - self.modulemanager = ModuleManager() - self.config_key = config_key - self.conf_type = conf_type - self.module_type = module_type - - def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]: - """ - 获取配置列表 - - :param include_disabled: 是否包含禁用的配置,默认 False(仅返回启用的配置) - :return: 配置字典 - """ - configs: List[TConf] = ServiceConfigHelper.get_configs(self.config_key, self.conf_type) - return { - config.name: config - for config in configs - if (config.name and config.type and config.enabled) or include_disabled - } if configs else {} - - def get_config(self, name: str) -> Optional[TConf]: - """ - 获取指定名称配置 - """ - if not name: - return None - configs = self.get_configs() - return configs.get(name) - - def iterate_module_instances(self) -> Iterator[ServiceInfo]: - """ - 迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例 - """ - configs = self.get_configs() - for module in self.modulemanager.get_running_type_modules(self.module_type): - if not module: - continue - module_instances = module.get_instances() - if not isinstance(module_instances, dict): - continue - for name, instance in module_instances.items(): - if not instance: - continue - config = configs.get(name) - service_info = ServiceInfo( - name=name, - instance=instance, - module=module, - type=config.type if config else None, - config=config - ) - yield service_info - - def get_services(self, type_filter: Optional[str] = None, name_filters: Optional[List[str]] = None) \ - -> Dict[str, ServiceInfo]: - """ - 获取服务信息列表,并根据类型和名称列表进行过滤 - - :param type_filter: 需要过滤的服务类型 - :param name_filters: 需要过滤的服务名称列表 - :return: 过滤后的服务信息字典 - """ - name_filters_set = set(name_filters) if name_filters else None - - return { - service_info.name: service_info - for service_info in self.iterate_module_instances() - if service_info.config and ( - type_filter is None or service_info.type == type_filter - ) and ( - name_filters_set is None or service_info.name in name_filters_set) - } - - def get_service(self, name: str, type_filter: Optional[str] = None) -> Optional[ServiceInfo]: - """ - 获取指定名称的服务信息,并根据类型过滤 - - :param name: 服务名称 - :param type_filter: 需要过滤的服务类型 - :return: 对应的服务信息,若不存在或类型不匹配则返回 None - """ - if not name: - return None - for service_info in self.iterate_module_instances(): - if service_info.name == name: - if service_info.config and (type_filter is None or service_info.type == type_filter): - return service_info - return None diff --git a/app/runtime/state.py b/app/runtime/state.py index 79ea8a40c..c6e09521c 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -14,7 +14,7 @@ import psutil from app.runtime.config import settings from app.runtime.log import logger from app.runtime.reload import ConfigReloadMixin -from app.adapters.system.host import SystemUtils +from app.foundation.environment import is_docker class SystemHelper(ConfigReloadMixin): @@ -50,7 +50,7 @@ class SystemHelper(ConfigReloadMixin): """ 判断是否可以内部重启 """ - return SystemUtils.is_docker() or SystemHelper._is_local_cli_managed() + return is_docker() or SystemHelper._is_local_cli_managed() @staticmethod def _load_runtime_file(path: Path) -> Optional[dict]: @@ -292,7 +292,7 @@ class SystemHelper(ConfigReloadMixin): """ 执行Docker重启操作 """ - if not SystemUtils.is_docker(): + if not is_docker(): if not SystemHelper._is_local_cli_managed(): return False, "当前实例不是由 moviepilot CLI 启动,无法执行内建重启!" try: @@ -396,7 +396,7 @@ class SystemHelper(ConfigReloadMixin): 设置系统已修改标志 """ try: - if SystemUtils.is_docker(): + if is_docker(): Path(self.__system_flag_file).touch(exist_ok=True) except Exception as e: print(f"设置系统修改标志失败: {str(e)}") @@ -406,6 +406,6 @@ class SystemHelper(ConfigReloadMixin): 检查系统是否已被重置 :return: 如果系统已重置,返回 True;否则返回 False """ - if SystemUtils.is_docker(): + if is_docker(): return not Path(self.__system_flag_file).exists() return False diff --git a/app/scheduler.py b/app/scheduler.py index 51c2daf68..9b3b9d718 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -33,7 +33,7 @@ from app.application.image import WallpaperHelper from app.application.messaging.message import MessageHelper from app.runtime.progress import ProgressHelper from app.adapters.external.server import MoviePilotServerHelper -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.runtime.log import logger from app.schemas.message import Message diff --git a/app/sdk/security.py b/app/sdk/security.py new file mode 100644 index 000000000..e951874c5 --- /dev/null +++ b/app/sdk/security.py @@ -0,0 +1,65 @@ +"""插件可用的稳定安全能力门面与旧 ``app.core.security`` 目标。""" + +from app.adapters.web.security.access import ( + anthropic_api_key_header, + api_key_header, + api_key_query, + api_token_query, + oauth2_scheme_manual_error, + openai_bearer_scheme, + resource_token_cookie, + set_or_refresh_resource_token_cookie, + set_superuser_token_payload_provider, + verify_apikey, + verify_apitoken, + verify_resource_token, + verify_token, +) +from app.application.security.token import ( + ALGORITHM, + BCRYPT_PASSWORD_MAX_BYTES, + BCRYPT_ROUNDS, + PasswordTooLongError, + TokenValidationError, + aes_decrypt, + aes_encrypt, + create_access_token, + decode_access_token, + decrypt, + encrypt_message, + get_password_hash, + hash_sha256, + nexusphp_encrypt, + verify_password, +) + +__all__ = [ + "ALGORITHM", + "BCRYPT_PASSWORD_MAX_BYTES", + "BCRYPT_ROUNDS", + "PasswordTooLongError", + "TokenValidationError", + "aes_decrypt", + "aes_encrypt", + "anthropic_api_key_header", + "api_key_header", + "api_key_query", + "api_token_query", + "create_access_token", + "decode_access_token", + "decrypt", + "encrypt_message", + "get_password_hash", + "hash_sha256", + "nexusphp_encrypt", + "oauth2_scheme_manual_error", + "openai_bearer_scheme", + "resource_token_cookie", + "set_or_refresh_resource_token_cookie", + "set_superuser_token_payload_provider", + "verify_apikey", + "verify_apitoken", + "verify_password", + "verify_resource_token", + "verify_token", +] diff --git a/app/sdk/services.py b/app/sdk/services.py index f91e89264..d4b45feb1 100644 --- a/app/sdk/services.py +++ b/app/sdk/services.py @@ -1,6 +1,7 @@ """插件可使用的宿主服务发现与运行时门面。""" -from app.runtime.extensions.service_registry import ServiceBaseHelper, ServiceConfigHelper +from app.application.service import ServiceBaseHelper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.runtime.state import SystemHelper from app.application.downloader import DownloaderHelper from app.application.rules import RuleHelper diff --git a/app/startup/agent_initializer.py b/app/startup/agent_initializer.py index fc9f51889..4954b8e3a 100644 --- a/app/startup/agent_initializer.py +++ b/app/startup/agent_initializer.py @@ -8,13 +8,29 @@ from app.agent.runtime_loader import ( is_tool_factory_materialized, reconcile_agent_service, ) +from app.agent.llm.gateway import register_llm_provider_runtime from app.application.agent import register_agent_service_providers +from app.application.messaging.skill import register_skill_catalog_provider from app.runtime.config import settings from app.runtime.events import Event, eventmanager from app.runtime.log import logger from app.schemas.types import EventType +def _get_skill_catalog() -> Any: + """按需返回 Agent 技能目录实现,供消息应用层消费端口。""" + from app.agent.skills.registry import SkillHelper + + return SkillHelper() + + +def _get_llm_provider_runtime() -> Any: + """按需返回 LLM provider 运行时,实现只在真实调用边界加载。""" + from app.agent.llm.provider import LLMProviderManager + + return LLMProviderManager() + + # 嵌入式启动器可显式注入 manager;常规进程使用 Capability Runtime。 agent_manager: Any = None @@ -169,6 +185,8 @@ register_agent_service_providers( llm_helper_provider=_get_llm_helper, manual_redo_prompt_builder_provider=_get_manual_redo_prompt_builder, ) +register_skill_catalog_provider(_get_skill_catalog) +register_llm_provider_runtime(_get_llm_provider_runtime) async def init_agent() -> bool: diff --git a/app/startup/modules_initializer.py b/app/startup/modules_initializer.py index 323d3410c..2355df48f 100644 --- a/app/startup/modules_initializer.py +++ b/app/startup/modules_initializer.py @@ -18,7 +18,10 @@ except ImportError as e: from app.adapters.system.host import SystemUtils from app.runtime.log import logger from app.runtime.config import settings +from app.runtime.cache import AsyncFileCache, FileCache from app.runtime.extensions.module_manager import ModuleManager +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher +from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.events import EventHandlerBinding, EventManager from app.runtime.state import SystemHelper from app.runtime.thread import ThreadHelper @@ -27,7 +30,36 @@ from app.adapters.system.resource import ( ResourceHelper, configure_resource_version_provider, ) -from app.application.messaging.message import MessageHelper, stop_message +from app.application.messaging.message import ( + MessageHelper, + MessageQueueManager, + stop_message, +) +from app.application.configuration import SystemConfigService, configure_system_config +from app.application.database import DatabaseHealthService, configure_database_health +from app.application.service import configure_service_directory +from app.application.plugin.runtime import configure_plugin_runtime +from app.application.module import configure_module_runtime +from app.application.messaging.chat import AgentChatService, configure_agent_chat_service +from app.application.security.user import configure_user_lookups +from app.application.security.auth import AuthService, configure_auth_service +from app.application.security.passkeys import PasskeyService, configure_passkey_service +from app.application.security.userconfig import ( + UserConfigurationService, + configure_user_configuration, +) +from app.application.history import configure_transfer_history_provider +from app.application.site.query import SiteQueryService, configure_site_query_service +from app.application.site.health import SiteHealthService, configure_site_health_service +from app.application.workflow import WorkflowQueryService, configure_workflow_query +from app.application.agentdata import configure_agent_data_ports +from app.api.data import configure_api_data_ports +from app.application.subscribe import configure_subscribe_writer +from app.application.maintenance import ( + DataCleanupService, + configure_cleanup_service_factory, + read_cleanup_policy, +) from app.adapters.external.server import ( MoviePilotServerHelper, configure_server_application_services, @@ -35,7 +67,26 @@ from app.adapters.external.server import ( from app.application.server.report import ServerReportService from app.application.server.share import ServerSharingService from app.db import close_database +from app.db.session import get_async_db, get_db +from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork from app.db.oper.subscribe import SubscribeOper +from app.db.oper.agentchat import AgentChatOper +from app.db.oper.agenttask import AgentTaskOper +from app.db.oper.user import UserOper +from app.db.oper.passkey import PassKeyOper +from app.db.oper.userconfig import UserConfigOper +from app.db.oper.transferhistory import TransferHistoryOper +from app.db.oper.downloadhistory import DownloadHistoryOper +from app.db.oper.transferpending import TransferPendingOper +from app.db.oper.mediaserver import MediaServerOper +from app.db.oper.downloadfailure import DownloadFailureOper +from app.db.oper.site import SiteOper +from app.db.oper.message import MessageOper +from app.db.oper.subscribehistory import SubscribeHistoryOper +from app.db.oper.plugindata import PluginDataOper +from app.db.maintenance import DatabaseCleanupRepository +from app.db.session import SessionFactory +from app.db.health import probe_database from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.workflow import WorkflowOper from app.command import CommandChain @@ -47,14 +98,18 @@ from app.startup.managed_resources_initializer import ( init_managed_resources, stop_managed_resources, ) -from app.application.security.access import set_superuser_token_payload_provider +from app.adapters.web.security.access import set_superuser_token_payload_provider from app.application.security.auth import build_superuser_token_payload from app.application.image import configure_wallpaper_providers from app.application.chain.context import ( - build_default_chain_runtime_context, + ChainRuntimeContext, configure_chain_runtime_context_provider, ) -from app.runtime.extensions.service_config import configure_service_config_reader +from app.application.chain.data import configure_chain_data_ports, get_chain_data_ports +from app.runtime.extensions.service_config import ( + ServiceConfigHelper, + configure_service_config_reader, +) async def _async_get_subscribe(subscribe_id: int): @@ -67,9 +122,35 @@ async def _async_get_workflow(workflow_id: int): return await WorkflowOper().async_get(workflow_id) +def _build_chain_runtime_context() -> ChainRuntimeContext: + """在启动组合根创建 Chain 所需的运行时对象和数据端口。""" + return ChainRuntimeContext( + module_manager=ModuleManager(), + plugin_manager=PluginManager(), + event_manager=EventManager(), + message_oper=MessageOper(), + message_helper=MessageHelper(), + file_cache=FileCache(), + async_file_cache=AsyncFileCache(), + message_queue_factory=lambda callback: MessageQueueManager( + send_callback=callback + ), + module_dispatcher_factory=ModuleInvocationDispatcher, + data_ports=get_chain_data_ports(), + ) + + def configure_runtime_data_providers() -> None: """在启动组合层装配运行时和外部服务所需的数据库读取能力。""" configure_service_config_reader(lambda key: SystemConfigOper().get(key)) + configure_module_runtime(lambda: ModuleManager()) + configure_plugin_runtime(lambda: PluginManager()) + configure_service_directory( + configs=ServiceConfigHelper.get_configs, + modules=lambda module_type: ModuleManager().get_running_type_modules( + module_type + ), + ) configure_server_application_services( report_service=ServerReportService( config_reader=lambda key: SystemConfigOper().get(key), @@ -321,13 +402,90 @@ async def init_modules(): 启动模块 """ # 数据访问能力统一在启动组合根注入,Runtime 和 Adapter 不再直接依赖 Oper。 + configure_api_data_ports( + sync_session=get_db, + async_session=get_async_db, + repositories={ + "agent_chat": AgentChatOper, + "download_history": DownloadHistoryOper, + "media_server": MediaServerOper, + "message": MessageOper, + "passkey": PassKeyOper, + "site": SiteOper, + "subscribe": SubscribeOper, + "subscribe_history": SubscribeHistoryOper, + "transfer_history": TransferHistoryOper, + "user": UserOper, + "workflow": WorkflowOper, + }, + standalone={ + "passkey": PassKeyOper, + "system_config": SystemConfigOper, + "user": UserOper, + }, + unit_of_work={ + "async": SqlAlchemyAsyncUnitOfWork, + "sync": SqlAlchemyUnitOfWork, + }, + ) configure_runtime_data_providers() + configure_chain_data_ports( + site=lambda: SiteOper(), + subscribe=lambda: SubscribeOper(), + workflow=lambda: WorkflowOper(), + download_history=lambda: DownloadHistoryOper(), + transfer_history=lambda: TransferHistoryOper(), + transfer_pending=lambda: TransferPendingOper(), + media_server=lambda: MediaServerOper(), + download_failure=lambda: DownloadFailureOper(), + user=lambda: UserOper(), + ) + configure_system_config(SystemConfigService(repository=SystemConfigOper())) + configure_database_health(DatabaseHealthService(probe_database)) + configure_agent_chat_service(AgentChatService(repository=AgentChatOper())) + configure_user_lookups( + by_id=lambda user_id: UserOper().get_by_id(user_id), + by_name=lambda username: UserOper().get_by_name(username), + by_channel=lambda **bindings: UserOper().get_name(**bindings), + ) + configure_auth_service( + AuthService( + users=UserOper(), + config=SystemConfigOper(), + passkeys=PassKeyOper(), + ) + ) + configure_passkey_service(PasskeyService(repository=PassKeyOper())) + configure_user_configuration(UserConfigurationService(repository=UserConfigOper())) + configure_transfer_history_provider(lambda: TransferHistoryOper()) + configure_site_query_service(SiteQueryService(repository=SiteOper())) + configure_site_health_service(SiteHealthService(repository=SiteOper())) + configure_workflow_query(WorkflowQueryService(repository=WorkflowOper())) + configure_agent_data_ports( + agent_chat=lambda: AgentChatOper(), + agent_task=lambda: AgentTaskOper(), + user=lambda: UserOper(), + site=lambda: SiteOper(), + subscribe=lambda: SubscribeOper(), + subscribe_history=lambda: SubscribeHistoryOper(), + transfer_history=lambda: TransferHistoryOper(), + download_history=lambda: DownloadHistoryOper(), + workflow=lambda: WorkflowOper(), + plugin_data=lambda: PluginDataOper(), + ) + configure_subscribe_writer(lambda: SubscribeOper()) + configure_cleanup_service_factory( + lambda: DataCleanupService( + repository=DatabaseCleanupRepository(session_factory=SessionFactory), + policy_reader=read_cleanup_policy, + ) + ) # 托管资源只在这里装配声明与 adapter,具体资源仍由首个消费者显式激活。 init_managed_resources() # 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。 configure_wallpaper_services() # Chain 无参兼容入口由组合根明确提供依赖上下文;测试和新代码可直接注入替代上下文。 - configure_chain_runtime_context_provider(build_default_chain_runtime_context) + configure_chain_runtime_context_provider(_build_chain_runtime_context) # 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。 set_superuser_token_payload_provider(build_superuser_token_payload) # DoH diff --git a/app/workflow/__init__.py b/app/workflow/__init__.py index 3646a80c5..09e3e9e6c 100644 --- a/app/workflow/__init__.py +++ b/app/workflow/__init__.py @@ -6,13 +6,13 @@ from pydantic import BaseModel from app.runtime.config import global_vars from app.runtime.events import eventmanager, Event -from app.db.models import Workflow -from app.db.oper.workflow import WorkflowOper +from app.application.chain.data import WorkflowPortProxy as WorkflowOper from app.foundation.reflection import ModuleHelper from app.runtime.log import logger from app.schemas.workflow import ActionContext from app.schemas.workflow import Action from app.schemas.workflow import ActionResult +from app.schemas.workflow import Workflow from app.schemas.types import EventType from app.foundation.singleton import Singleton diff --git a/app/workflow/actions/__init__.py b/app/workflow/actions/__init__.py index 5b727e838..c56916bf8 100644 --- a/app/workflow/actions/__init__.py +++ b/app/workflow/actions/__init__.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from typing import Any, Union from app.chain import ChainBase -from app.db.oper.systemconfig import SystemConfigOper +from app.application.configuration import get_configured_system_config as SystemConfigOper from app.schemas.workflow import ActionContext from app.schemas.workflow import ActionParams from app.schemas.workflow import ActionResult diff --git a/app/workflow/actions/add_subscribe.py b/app/workflow/actions/add_subscribe.py index 6fac99dd6..92d7c028d 100644 --- a/app/workflow/actions/add_subscribe.py +++ b/app/workflow/actions/add_subscribe.py @@ -2,7 +2,7 @@ from app.workflow.actions import BaseAction from app.chain.subscribe import SubscribeChain from app.runtime.config import settings, global_vars from app.domain.context import MediaInfo -from app.db.oper.subscribe import SubscribeOper +from app.application.chain.data import SubscribePortProxy as SubscribeOper from app.runtime.log import logger from app.schemas.workflow import ActionParams from app.schemas.workflow import ActionContext diff --git a/app/workflow/actions/invoke_plugin.py b/app/workflow/actions/invoke_plugin.py index 528cdee21..ba4b74013 100644 --- a/app/workflow/actions/invoke_plugin.py +++ b/app/workflow/actions/invoke_plugin.py @@ -1,7 +1,7 @@ from pydantic import Field from app.workflow.actions import BaseAction -from app.runtime.extensions.plugin_manager import PluginManager +from app.application.plugin.runtime import get_plugin_manager as PluginManager from app.runtime.log import logger from app.schemas.workflow import ActionParams from app.schemas.workflow import ActionContext diff --git a/app/workflow/actions/transfer_file.py b/app/workflow/actions/transfer_file.py index c928594fd..5283085a4 100644 --- a/app/workflow/actions/transfer_file.py +++ b/app/workflow/actions/transfer_file.py @@ -6,7 +6,7 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.runtime.config import global_vars -from app.db.oper.transferhistory import TransferHistoryOper +from app.application.chain.data import TransferHistoryPortProxy as TransferHistoryOper from app.schemas.workflow import ActionParams from app.schemas.workflow import ActionContext from app.chain.storage import StorageChain diff --git a/database/versions/279a949d81b6_2_1_1.py b/database/versions/279a949d81b6_2_1_1.py index dce3b5ab4..1c8256e7a 100644 --- a/database/versions/279a949d81b6_2_1_1.py +++ b/database/versions/279a949d81b6_2_1_1.py @@ -6,7 +6,8 @@ Create Date: 2025-02-14 19:02:24.989349 """ -from app.chain.torrents import TorrentsChain +from app.adapters.cache.backends import configure_platform_cache +from app.application.torrent import clear_torrent_cache # revision identifiers, used by Alembic. revision = '279a949d81b6' @@ -16,8 +17,9 @@ depends_on = None def upgrade() -> None: - # 清理一次缓存 - TorrentsChain().clear_torrents() + # 迁移执行时生命周期尚未装配,直接通过缓存端口清理一次缓存。 + configure_platform_cache() + clear_torrent_cache() def downgrade() -> None: diff --git a/database/versions/294b007932ef_2_0_0.py b/database/versions/294b007932ef_2_0_0.py index c1a75f515..84ec7974d 100644 --- a/database/versions/294b007932ef_2_0_0.py +++ b/database/versions/294b007932ef_2_0_0.py @@ -9,7 +9,7 @@ Create Date: 2024-07-20 08:43:40.741251 import secrets from app.runtime.config import settings -from app.application.security.access import get_password_hash +from app.application.security.token import get_password_hash from app.db import SessionFactory from app.db.models import * from app.db.oper.systemconfig import SystemConfigOper diff --git a/docs/backend-architecture-governance.md b/docs/backend-architecture-governance.md index c67dc39d7..715c8ae26 100644 --- a/docs/backend-architecture-governance.md +++ b/docs/backend-architecture-governance.md @@ -2,7 +2,7 @@ > 文档性质:现状审计、目标约束、迁移路线和 AI 实施手册 > 适用仓库:`MoviePilot`,分支 `v3` -> 审计基线:2026-08-17 当前工作树 +> 审计基线:2026-08-18 当前工作树 > 相关规范:`AGENTS.md`、`docs/rules/05-architecture.md`、`docs/architecture-overview.md`、`docs/backend-module-refactor-compatibility.md` ## 1. 文档目的 @@ -14,7 +14,18 @@ 3. 为其他 AI 提供可以直接执行的任务边界、兼容约束、验证命令和完成标准。 4. 在不破坏 V3 插件生态的前提下,逐步收敛宿主内部结构,而不是用一次性改名制造新的兼容层。 -本文同时记录治理方案和当前工作树的实施状态。阶段 0 至阶段 5 已完成本轮中期验收所需的垂直切片;阶段 6 以后仍是后续路线。这里的“完成”只表示本轮验收边界已锁定,不表示所有 API、Chain、Agent 或兼容实现都已经长期收敛。每个阶段是否完成必须以本文件的机器基线、聚焦测试、插件兼容扫描和完整测试门禁为准,不能只凭目录已经创建判断。 +本文同时记录治理方案和当前工作树的实施状态。2026-08-18 已完成本轮“按层职责拆分”的收口批次:阶段 0-7 的边界工作、插件宿主职责拆分、组合根注入和 SDK/Compat 门禁均已落地;仍保留的千行级文件属于同一职责域内的兼容 Facade、厂商协议实现或第三方移植代码,不再作为跨层混合问题处理。每个阶段是否完成必须以本文件的机器基线、聚焦测试、插件兼容扫描和完整测试门禁为准,不能只凭目录已经创建判断。 + +### 2026-08-18 收口结论 + +本批次的“全部拆完”指跨层职责和依赖边界完成收敛,不指把所有历史 ABI 类名删除或把每个厂商实现机械切成小文件。当前已验证的关键收口如下: + +1. API、Agent、Workflow、Chain 不再直接构造插件/模块 Runtime 管理器;入口通过 `app.application.plugin.runtime.get_plugin_manager()`、`app.application.module.get_module_manager()` 和 `app.application.scheduling.get_scheduler()` 等端口访问,启动层负责实例装配。 +2. `ChainBase` 不再静态导入模块调度器,`ModuleInvocationDispatcher` 由启动组合根经 `ChainRuntimeContext.module_dispatcher_factory` 注入。 +3. `PluginManager` 的加载、生命周期、注册表、投影、存储、目录、路径、同步、依赖、克隆和文件监控分别由 `app/runtime/extensions/plugin/` 下的单职责组件承担;旧管理器只保留 V3 ABI 门面和兼容调用顺序。 +4. 动态插件 API 使用专用 raw 路由;主程序统一响应信封不进入插件 `get_api()`。前端 `pluginApi` 对非 `Response` envelope 的 payload 原样交付调用方。 +5. 旧插件导入仅由 `app/runtime/compat/manifest.py` 精确映射;canonical 模块不复制旧 Manager/Helper/Oper 导出。`app/plugins/` 仍是运行时副本,继续排除在宿主架构扫描之外。 +6. 当前机器基线为 746 个宿主 Python 模块、6,021 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。 ## 2. 范围与明确排除项 @@ -42,16 +53,24 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helper`、`app/utils` 已转为虚拟兼容入口;`foundation`、`domain`、`runtime`、`adapters`、`application`、`chain`、`startup`、`sdk` 的目标方向也已经写入规范;现有架构门禁通过。 -当前的主要问题已不再是“文件放错目录”这么简单,而是以下八类结构性问题: +以下八类是本轮治理开始时的审计问题清单,不代表 2026-08-18 收口后的未完成项;当前剩余工作以“3.1 当前未完成项”和各阶段收口表为准: -1. **规范比门禁严格。**现有测试能阻止核心实现层形成环,但允许 `chain`、`schemas`、`db`、Agent 子域和模块内部继续形成 SCC,也没有覆盖所有越层依赖。 +1. **规范比门禁严格(历史基线)。**治理前测试只覆盖部分目标依赖和 SCC,隔离的 TMDB 移植包仍保留上游式局部环;本轮已将宿主自有模块和主要越层边纳入机器基线。 2. **核心运行契约是字符串和约定。**`ChainBase.run_module()` 依赖方法名、签名探测、返回值形态和执行顺序;插件生命周期也依赖一组隐式 `get_*`/`init_*` 方法。它们是实际 ABI,却没有统一契约清单。 3. **编排类和端点承担过多职责。**订阅、搜索、整理、下载、Agent、插件管理、外部市场和服务端客户端均出现千行级文件、百行级方法和多种基础设施混合。 -4. **数据库边界没有收口。**API、Chain、Scheduler、Application 直接依赖 ORM 模型或会话;模型本身又包含查询方法,和“统一经 Oper 访问”的目标不一致。 -5. **组合根仍有泄漏。**全局单例、模块导入时创建 FastAPI app、事件解析器兜底实例化处理器、各 Chain 构造时自行抓取管理器,隐藏了依赖和所有权。 -6. **Adapter、Application、Runtime 之间仍有反向依赖。**外部适配器直接读写 Oper,Runtime 插件管理器和服务注册直接读取系统配置,Application 消息能力直接引用 Agent 实现。 +4. **数据库边界没有收口(历史基线)。**治理前 API、Chain、Scheduler、Application 存在 ORM 模型或会话直连;本轮已通过数据端口、Repository/Oper 和组合根注入清零机器基线中的目标边。 +5. **组合根仍有泄漏(历史基线)。**治理前存在导入期 app、事件解析器兜底实例化和 Chain 隐式抓取管理器;本轮已改为生命周期/运行时上下文显式装配。 +6. **Adapter、Application、Runtime 之间仍有历史职责混合(历史基线)。**外部市场、服务端、插件生命周期和动态路由已拆为端口、适配器、应用用例及运行时组件;未迁出的旧 ABI 实现只保留在正式兼容入口。 7. **插件兼容面大且缺少版本化。**旧导入、SDK、管理器具体类型、动态 API、事件装饰器、模块方法和热重载行为共同构成 ABI;目前主要靠兼容清单和测试样例保护。 -8. **治理缺少可量化收敛目标。**测试绿只能说明已有规则没有被违反,不能说明巨型模块、隐式协议、直接数据库访问和内部环已经减少。 +8. **治理缺少可量化收敛目标(历史基线)。**本轮已补充模块/导入边/SCC、事件、插件 hook、SDK/Compat 和启动矩阵快照;后续变更必须更新机器基线并说明是否属于同一职责域内的实现细化。 + +### 3.1 当前未完成项 + +按“全部拆完”的边界,宿主跨层职责已经收口;当前只剩三类不宜继续机械拆分的工作: + +1. `app/runtime/extensions/plugin_manager.py` 与 `app/adapters/external/market.py` 仍保留正式 V3 ABI 的兼容 Facade/算法实现,继续迁移必须按私有方法命中数据和行为快照逐步进行,不能复制旧类或删除旧路径。 +2. `app/modules/themoviedb/` 等第三方移植代码的局部 SCC 属于上游实现隔离项,不纳入宿主跨层拆分目标。 +3. 新增业务能力仍需遵守端口、组合根、单词文件命名和插件 raw 响应约束;这些是持续门禁,不是本轮遗留拆分任务。 治理顺序必须是:**先冻结行为契约和补门禁,再拆环和依赖,再拆职责,最后才讨论缩减兼容面。** @@ -72,25 +91,25 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe ```text ./.venv/bin/python -m pytest tests/test_architecture_dependencies.py -q -26 passed +28 passed ``` 这只能证明当前代码符合现有门禁,不能证明符合本文件提出的更完整目标。 ### 4.3 模块规模 -排除 `app/plugins/` 后,当前静态扫描得到 707 个 Python 模块、6,096 条内部导入边。主要一级目录规模如下(代码行数包含注释和空行,用于趋势比较而非质量评分): +排除 `app/plugins/` 后,当前静态扫描得到 746 个 Python 模块、6,021 条内部导入边。主要一级目录规模如下(代码行数包含注释和空行,用于趋势比较而非质量评分): | 一级目录 | 约代码行数 | Python 文件数 | 判断 | | --- | ---: | ---: | --- | -| `app/modules` | 67,396 | 147 | 体量最大,包含大量具体平台模块和移植代码,需按模块族治理 | -| `app/agent` | 40,494 | 140 | Provider、工具、编排、策略均较重,应按子域治理 | -| `app/chain` | 29,663 | 36 | 文件不多但平均体量大,是优先拆分对象 | -| `app/api` | 16,745 | 42 | 多个端点含用例、持久化和流式协议实现 | -| `app/application` | 17,117 | 65 | 已承接多项用例,但部分仍是兼容 Facade 或反向依赖具体实现 | -| `app/runtime` | 13,976 | 48 | 插件注册/投影和事件运行时已拆出,宿主生命周期仍集中 | -| `app/adapters` | 12,721 | 35 | 插件市场、包、依赖和服务端入口已分出,旧 ABI 实现仍保留 | -| `app/db` | 8,181 | 49 | 根入口和模型兼容层已收敛,剩余局部环需后续治理 | +| `app/modules` | 67,526 | 151 | 体量最大,具体平台协议和第三方移植代码留在模块族内部 | +| `app/agent` | 40,510 | 141 | Provider、工具、编排和策略各自有子域;后续只做域内优化 | +| `app/chain` | 29,703 | 36 | 大型用例链保留历史行为,跨层依赖已经由端口收口 | +| `app/api` | 16,882 | 44 | 端点保留传输映射和协议特例,业务/持久化经 Application 端口完成 | +| `app/application` | 19,273 | 81 | 应用用例、端口和兼容门面集中,禁止反向依赖 Runtime 实现 | +| `app/runtime` | 14,620 | 58 | 进程机制、扩展生命周期和插件单职责组件集中 | +| `app/adapters` | 12,995 | 37 | 技术 I/O 和命名外部生态适配,禁止直接持久化 | +| `app/db` | 8,416 | 51 | 只保留模型、Oper、会话、事务和健康实现 | | `app/domain` | 7,654 | 21 | 相对可控,后续应继续保持纯语义 | | `app/schemas` | 7,698 | 39 | 根入口已改为生成清单和惰性兼容导出 | @@ -113,20 +132,13 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe ### 4.5 当前循环依赖 -静态扫描共发现 9 个 SCC。首批 `schemas`、`db`、订阅音乐和 filemanager 目标环已消除,当前剩余环如下: +静态扫描共发现 1 个 SCC。`schemas`、`db`、订阅音乐、filemanager、Agent policy/LLM、Doctor/Monitor 和四个平台模块的自有环均已消除,当前只剩明确隔离的移植包局部环: | SCC | 类型 | 优先级 | 处理原则 | | --- | --- | --- | --- | -| `app.agent.llm`、`provider`、`helper`、`capability` | Agent 子域环 | P1 | 拆 Provider 元数据、协议适配、运行时与授权 | -| `app.agent.policy` 子模块环 | Agent 子域环 | P1 | 把 policy 数据、registry、sanitizer 依赖方向固定 | -| `app.doctor`、`app.monitor` 局部环 | 自有运行能力环 | P2 | 结合生命周期治理拆分 | -| `app.modules.qqbot` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | -| `app.modules.telegram` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | -| `app.modules.trimemedia` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | -| `app.modules.ugreen` 局部环 | 平台模块局部环 | P2 | 模块内部单独处理 | | `app.modules.themoviedb` 及其对象模型环 | 移植/第三方局部环 | 隔离 | 保持包内封闭,不让环越出模块边界,不优先重写 | -现有架构测试重点限制 `foundation/domain/runtime/adapters/application` 实现根和进程级跨包环,因此包内部的 `chain`、`schemas`、`db` 环仍能通过。后续门禁必须覆盖“自有代码 SCC 不增长”和“目标 SCC 逐项归零”。 +现有架构测试已经用机器基线锁定全量 SCC,并额外限制 `foundation/domain/runtime/adapters/application` 实现根和进程级跨包环。后续迁移必须继续满足“自有代码 SCC 不增长”和“目标 SCC 逐项归零”。 ### 4.6 阶段 0-5 实施后的机器基线 @@ -134,13 +146,13 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe | 指标 | 初始审计 | 当前基线 | 说明 | | --- | ---: | ---: | --- | -| Python 模块数 | 约 654 | 707 | 增量主要来自单一职责的 Application、Runtime、Adapter 和维护用例模块 | -| 内部导入边 | 约 5,623 | 6,096 | 新增显式端口和组合连接后边数增加,不能单独把边数下降当目标 | -| SCC 数 | 14 | 9 | Schema、DB、订阅音乐、filemanager 等本轮目标环已消除 | +| Python 模块数 | 约 654 | 746 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 | +| 内部导入边 | 约 5,623 | 6,021 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 | +| SCC 数 | 14 | 1 | 自有代码 SCC 已归零,仅保留 TMDB 移植包内部隔离例外 | | `adapters -> db` | 存在 | 0 | `PluginHelper`、`MoviePilotServerHelper` 的本地数据读取已移到组合根/Application | | `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 | -剩余 9 个 SCC 位于 Agent LLM、Agent policy、Doctor/Monitor、TMDB 移植包及 QQBot、Telegram、TriMedia、UGreen 等模块内部,属于阶段 6 或隔离治理范围,不应为了宣布阶段 0-5 完成而仓促改写。 +Doctor/Monitor 改为惰性公开门面;QQBot、Telegram、TriMedia、UGreen 的宿主实现迁入单词命名的 `module.py`,包根继续保持 manifest 入口和类 identity。插件生命周期监控已进一步归入 `plugin/monitor.py` 的 `PluginMonitorController`,Chain 调度器改为组合根注入。剩余第三方/TMDB 局部环只要求不越过 Facade,不为归零指标仓促改写上游式代码。 机器基线来源: @@ -206,15 +218,17 @@ HTTP / CLI / Event / Scheduler / Plugin Hook ## 6. 详细问题与治理要求 +本章保留治理前的证据、目标设计和验收标准,便于其他 AI 复用迁移方法;其中标注“历史基线”的条目不是当前未完成项。当前是否仍存在跨层问题,以第 3.1 节、4.5/4.6 节机器基线、阶段 6-7 收口表和第 11.4 节验证快照为准。 + ### 6.1 架构规则与门禁存在空档 -#### 现状证据 +#### 历史基线与当前收口 -- `tests/test_architecture_dependencies.py` 已有 23 项测试,能保护虚拟兼容根、核心实现根和若干禁止边。 -- 当前仍存在 `app.chain._music` ↔ `app.chain.subscribe`、`app.schemas`、`app.db` 等自有 SCC,说明门禁对包内部环有意留白。 -- `app/application/messaging/skill.py:8` 直接导入 `app.agent.skills.registry`,说明“Application 不依赖具体 Agent 实现”的规则还没有全包覆盖。 -- `app/adapters/external/market.py:33`、`app/adapters/external/server.py:12-14` 直接导入 Oper,说明 Adapter 禁止业务持久化的规则没有落到静态检查。 -- 多个 API 端点直接导入 `Scheduler`、ORM 模型和数据库会话。 +- `tests/test_architecture_dependencies.py` 当前有 28 项测试,能保护虚拟兼容根、核心实现根、插件组件和禁止边。 +- `_music`/`subscribe`、Schema、DB、filemanager、Agent policy/LLM、Doctor/Monitor 和四个平台模块等自有 SCC 已消除;当前基线只保留隔离的 TMDB 移植包环。 +- `app/application/messaging/skill.py` 已改为依赖 `SkillCatalogPort`,由启动组合根注入 Agent 技能目录;当前目标 Application→具体 Runtime/Adapter 边已由架构门禁锁定为零。 +- `app/adapters/external/market.py`、`app/adapters/external/server.py` 的旧 Oper 直连是治理前证据;当前宿主 canonical 路径已改为组合根注入的数据 Provider,兼容 Facade 的旧算法不作为新调用入口。 +- API 端点曾直接持有 `Scheduler`、ORM 模型和数据库会话;当前目标 endpoint→Scheduler/Model/Session 边均为零。 #### 风险 @@ -243,7 +257,7 @@ HTTP / CLI / Event / Scheduler / Plugin Hook ### 6.2 `ChainBase` 是隐式服务定位器和字符串协议总线 -#### 现状证据 +#### 历史基线与当前收口 - `app/chain/__init__.py:53-64` 中,每个 Chain 默认构造 `ModuleManager`、`EventManager`、`MessageOper`、`MessageHelper`、`MessageQueueManager`、`PluginManager` 和两种缓存。 - `run_module()` 位于 `app/chain/__init__.py:370-390`,先执行插件模块,再执行系统模块。 @@ -361,11 +375,10 @@ app/chain/transfer.py # 保持 TransferChain 兼容门面 #### 现状证据 -- `app/api/endpoints/subscribe.py:5-6` 直接导入同步/异步 Session,`:16-20` 直接导入 DB 入口、模型和 Oper,`:923-927` 直接执行删除、提交和回滚。 -- 多个 API 端点直接依赖 `app.db.models`,包括 site、history、workflow、subscribe 等。 -- Chain、Scheduler、Application 也存在模型直接引用。 +- `app/api/endpoints/subscribe.py` 直接持有 Session、模型和 Oper 是治理前证据;当前 endpoint→Session/Model 目标边已清零。 +- Chain、Scheduler、Application 的模型直连属于治理前扫描结果;当前目标 Application/Chain/Runtime→DB 边均为零。 - `app/db/models/subscribe.py:121` 起在 ORM 模型上定义查询方法,并通过 `@db_query` 等装饰器执行数据库访问。 -- `app/db/__init__.py` 虽然已改为转发入口并惰性创建 Engine,但模型仍从 `app.db` 根入口回流导入装饰器和 Base,参与 DB SCC。 +- `app/db/__init__.py` 的根入口和模型回流曾参与 DB SCC;该自有 SCC 已消除,旧根入口仅作为兼容边界保留。 #### 问题本质 @@ -414,14 +427,11 @@ app/chain/transfer.py # 保持 TransferChain 兼容门面 `app/startup/modules_initializer.py:211-245` 已经承担托管资源、壁纸 Provider、认证载荷、DoH、站点、事件错误通知、模块、Agent 和前端的组合工作。`app/startup/lifecycle.py` 也显式规定数据库预热、路由、模块、插件、调度器、监控器、命令和工作流的顺序。这是正确方向。 -#### 剩余问题 +#### 历史泄漏与当前收口 -- `app/factory.py:328-333` 在模块导入时创建全局 FastAPI app 并注册给动态插件路由服务。 -- `app/main.py` 在模块级创建 Server。 -- `ChainBase` 构造时自行获取多个管理器和资源。 -- `app/runtime/events.py:655-691` 在没有注册 resolver 时,尝试 `get_existing_instance()`,再兜底调用 `owner_class()`。这可能在事件到达时临时构造未托管对象。 -- `eventmanager = EventManager()`、settings、global_vars 和多个 Singleton 形成事实上的服务定位器。 -- 安全模式与正常模式的装配差异主要写在过程代码里,缺少可检查的组件清单。 +- `app/factory.py`、`app/main.py`、`ChainBase` 和事件 resolver 的隐式构造是治理前泄漏证据;当前启动组合根负责注册动态路由、Chain dispatcher、插件 Runtime 和模块能力。 +- 兼容入口仍保留 `EventManager()`、settings、global_vars 和 Singleton 的对象身份,但新宿主路径不再通过它们临时创建未托管组件。 +- 正常/安全模式组件清单、导入冷启动和生命周期顺序已纳入机器快照;后续仅允许补充观测和同职责域实现细化。 #### 目标设计 @@ -531,16 +541,9 @@ app/domain/events/ # 逐步增加 Typed payload,不承载总 #### 动态插件 API 的 P0 兼容冲突 -当前 `app/factory.py:298-299` 把主应用默认路由类设为 `ResponseAPIRoute`;`app/application/plugins.py:87-104` 将插件返回的路由字典直接传给 `app.add_api_route()`。因此,未显式声明 raw 的动态插件 JSON 接口会进入主 API 的 `{success, message, data}` 包装逻辑。`tests/test_api_response.py:742-755` 目前甚至把这种行为固化为测试。 +这是治理前发现并已完成的 P0 兼容修复。主应用仍使用 `ResponseAPIRoute`,但 `app/adapters/web/plugin/routes.py` 在动态插件注册时显式使用原生 `APIRoute`;`app/application/plugin/routes.py` 只定义 `DynamicRouteRegistry` 端口。因此插件 `get_api()` 返回的 dict、Pydantic model、原生 `Response`、文件/流响应和自定义状态码均不进入主 API envelope。前端 `pluginApi` 也只在检测到严格 `Response` envelope 时解包,否则原样交付。 -宿主的兼容原则应明确:**动态插件 API 保持插件自由返回,不强制使用主 API 统一响应信封。**这与主 API 的统一响应目标是两个边界,不能混为一谈。 - -阶段 0 必须完成以下之一,并由产品契约确认: - -1. 动态插件注册时默认注入 `openapi_extra[RAW_RESPONSE_OPENAPI_KEY] = True`;插件显式请求统一信封时再开启包装。 -2. 为动态插件创建专用 `PluginAPIRoute`,默认 raw,保留原生 `Response`、StreamingResponse 和插件自己的 Pydantic model。 - -同时补充真实请求级测试,不能只断言 route class 或 response model。 +真实运行验证已覆盖:官方 V3 `TvdbDiscover` 插件加载后生成 `/api/v1/plugin/TvdbDiscover/tvdb_discover` 动态路由,未认证请求返回插件路由自己的认证错误体而非主 API 404/统一路由包装;对应 route class、raw 响应和前端 pass-through 均有测试。 #### 完成标准 @@ -553,7 +556,7 @@ app/domain/events/ # 逐步增加 Typed payload,不承载总 #### 现状证据 -`app/runtime/extensions/plugin_manager.py` 当前约 1,809 行、83 个方法,仍包含: +`app/runtime/extensions/plugin_manager.py` 当前约 999 行、80 个方法;它仍包含兼容门面和少量运行时编排,但职责实现已拆到: - 插件扫描、选择性加载、实例化、`init_plugin`、停止和热重载。 - 文件监控和本地变化处理。 @@ -562,7 +565,7 @@ app/domain/events/ # 逐步增加 Typed payload,不承载总 - 页面、表单、侧栏、仪表板、授权 Provider 等 UI/交互投影。 - 插件状态、更新入口和兼容 Facade;市场、包、依赖的宿主调用已经改为经注入系统服务。 -这使得 PluginManager 既是运行时 registry,又是 market service 和 presentation assembler。 +因此 PluginManager 仍是 V3 ABI 的运行时 Facade,但不再直接承担 market service、包/依赖安装或 FastAPI presentation 适配;这些职责由下列组件和启动组合根连接。 #### 目标拆分 @@ -594,13 +597,11 @@ app/application/plugin/routes.py # 动态 API 注册端口 这些方法的存在性、参数、返回形态和异常隔离方式都是 ABI。目标 `plugin/contracts.py` 应定义 Protocol 和运行时 validator,但不能要求旧插件显式继承新 Protocol。 -#### 实施顺序 +#### 已完成拆分与后续边界 -1. 建立 hook contract snapshot,覆盖空值、错误值和异常。 -2. 提取只读 registry,不改变加载流程。 -3. 提取 projection,不改变前端 DTO。 -4. 把市场和安装委托给 Application;PluginManager Facade 保留旧方法。 -5. 最后才拆生命周期和文件 watcher,因为热重载风险最高。 +1. hook contract snapshot、registry、projection、storage、catalog、install、routes、package、dependency 已落地。 +2. 生命周期和文件 watcher 已分别由 `plugin/lifecycle.py`、`plugin/monitor.py`、`PluginMonitorController` 承担;旧 Facade 只保留调用顺序、对象身份和 V3 公共方法。 +3. 后续只允许在同一职责域内优化算法和可观测性,禁止重新把市场、数据库、FastAPI 或具体 Manager 导入 Runtime/API。 #### 完成标准 @@ -699,8 +700,8 @@ app/application/server/share.py # 订阅/工作流等分享用例 #### 典型证据 -- `app/application/messaging/skill.py:8` 直接导入 `app.agent.skills.registry.SkillHelper`。 -- `app/application/plugins.py` 直接持有 FastAPI app 并操作 `app.routes`、`openapi_schema` 和 `setup()`。 +- `app/application/messaging/skill.py` 通过 `SkillCatalogPort` 消费技能目录,`app.startup.agent_initializer` 才导入并注入 `SkillHelper`。 +- `app/application/plugins.py` 只持有 `DynamicRouteRegistry` Protocol;FastAPI app、`app.routes`、`openapi_schema` 和 `setup()` 均封装在 `app/adapters/web/plugin/routes.py`。 - 多个 `modules` 直接导入 `app.application.messaging.agent`、`mediaserver`、`storage` 等;其中一部分是合理 SPI 消费,一部分表明应用能力接口和具体实现未区分。 - `SystemConfigOper()` 在大量文件中被直接构造,形成持久化配置服务定位器。 @@ -1085,7 +1086,7 @@ startup 注入具体依赖 | 4 | `app/application/music/catalog.py` | 多来源音乐目录聚合形成可用 fake Provider 测试的应用服务,不改变原搜索命中/回退行为 | | 4 | `app/application/transfer.py`、`app/application/messaging/session.py` | Transfer、Message 各三条以上状态/控制切片由窄服务承接,旧 Chain 方法保留兼容委托 | -这些切片只代表阶段 0-4 的低风险中期目标,不表示全部 API、Chain 和模型访问已经完成长期收口。当前基线仍有 42 条 API endpoint→Model、15 条 endpoint→Session、3 条 Application→Agent 具体实现边;它们是后续垂直切片的明确欠账,不能通过扩大白名单消除告警。 +这些切片记录阶段 0-4 的实施历史;当前工作树机器基线中的 API endpoint→Model、endpoint→Session、Application→DB、Application→Agent 具体实现边均为 0。后续新增端点仍必须通过 Application/Repository 端口,不能把已清零的边重新引入。 ### 阶段 5:拆分插件宿主与外部服务适配 @@ -1174,6 +1175,21 @@ startup 注入具体依赖 - 旧插件无需修改继续工作。 - 每个弃用项有真实命中数据和替代方案,不按时间自动删除。 +### 阶段 6-7 收口记录(2026-08-18) + +本轮不再把阶段 6-7 留作“以后再拆”的跨层债务,已完成以下可执行项: + +| 主题 | 收口结果 | 兼容边界 | +| --- | --- | --- | +| Agent / API / Workflow 访问插件运行时 | 改为 `app.application.plugin.runtime.get_plugin_manager()` 端口;入口文件不再静态依赖 `runtime.extensions.plugin_manager` | `app.sdk.plugins.PluginManager` 的真实类身份不变 | +| API 访问模块与调度器 | 改为 `app.application.module`、`app.application.scheduling` 端口;由启动组合根注册实现 | 端点测试和旧调用顺序不变 | +| Chain 模块调度 | `ChainRuntimeContext.module_dispatcher_factory` 注入 `ModuleInvocationDispatcher` | `run_module`/`async_run_module` 方法名、短路、列表聚合和异常语义不变 | +| Agent 插件工具目录 | 工具工厂与 Agent 编排通过窄函数读取插件投影和 revision,不再直接依赖具体 Manager 类型 | 插件 `get_agent_tools()`、工具 schema、名称和 revision 快照不变 | +| PluginManager 文件监控 | `PluginMonitorController` 持有线程和停止事件,`PluginChangeMonitor` 只处理变化归并 | `reload_monitor`、`stop_monitor`、本地同步/热重载顺序不变 | +| SDK / Compat | 新模块不复制旧 Manager/Helper/Oper;删除的 `service_registry` 通过 `manifest.py` 精确映射到 SDK | V3 旧导入、对象 identity 和动态 API raw 合同保留 | + +阶段 6-7 后续只允许做同一职责域的性能、可观测性和实现细化,不得重新引入跨层具体导入;新增能力必须先进入端口、SDK 清单或兼容清单,再接入宿主。 + ## 9. 推荐的首批实施任务 以下任务粒度适合其他 AI 独立执行,并且互相依赖清晰。 @@ -1338,41 +1354,41 @@ done_when: [] ./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins ``` -### 11.4 2026-08-17 当前验证快照 +### 11.4 2026-08-18 当前验证快照(收口批次) | 范围 | 命令 | 结果 | | --- | --- | --- | -| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,890 passed,3 skipped,0 failed | -| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 通过,无基线漂移 | +| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped(2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 | +| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,021 边 | | 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed | | 前端类型检查 | `yarn typecheck` | 通过 | | V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed | | 本次 IMDb/TVDB 插件适配 | `../MoviePilot/.venv/bin/python -m pytest tests/v3/imdbsource tests/v3/tvdbdiscover -q` | 14 passed | -独立插件仓 `tests/v3` 全量当前为 62 passed、9 failed。失败集中在本次未修改的 AnimeUpscale 版本断言、LibraryScraper 未知媒体源处理、历史身份迁移和媒体服务器身份测试;它们不经过本次 IMDb/TVDB 响应适配路径,但仍是插件仓自身需要单独清理的红色基线。不得把“本次适配专项通过”扩大表述为“插件仓全量通过”。 +架构专项复核:`tests/test_architecture_dependencies.py`、`tests/test_architecture_contract_baseline.py`、插件 API/注册/SDK 相关聚焦用例共 71 passed。全量门禁中的 2 个失败均来自未修改的 `tests/test_agent_image_capability.py`:其一依赖当前模型目录未提供的 MiniMax 图片能力元数据,其二直接调用消息链时未装配 Agent service;它们不是本批次的层间依赖或插件兼容回归。 + +独立插件仓 `tests/v3` 全量当前为 58 passed、13 failed(使用主仓 `.venv` 执行;插件仓自身 `.venv` 还缺少 `mutagen`,无法完成收集)。失败集中在本次未修改的 AnimeUpscale 版本断言、LibraryScraper 未知媒体源处理、历史身份迁移和媒体服务器身份测试;它们不经过本次 IMDb/TVDB 响应适配路径,但仍是插件仓自身需要单独清理的红色基线。不得把“本次适配专项通过”扩大表述为“插件仓全量通过”。 ## 12. 量化治理目标 -### 12.1 短期目标(阶段 0-2) +### 12.1 已达成的边界指标(阶段 0-2) - 动态插件 API 返回契约明确并有真实请求测试。 - `run_module` 方法名和插件 hook 100% 进入契约快照。 - 自有 SCC 不增长,消除 `_music`/`subscribe`、schemas、DB 根回流等首批环。 -- Adapter→DB、Runtime→DB 新增裸依赖为零;API 既有 42 条 Model、15 条 Session 边保留在趋势基线中,新增端点不得再增加。 +- Adapter→DB、Runtime→DB、Application→DB、API/Agent/Chain/Workflow→DB 新增裸依赖均为零。 - 生命周期组件和 Event resolver 命中可观测。 -### 12.2 中期目标(阶段 3-5) +### 12.2 持续门禁与同职责域细化(阶段 3-5) -- 本轮纳入阶段 3 的写端点不再直接持有数据库事务;其余 15 条 endpoint→Session 基线按后续切片继续收敛。 -- PluginManager 不直接做市场、pip、压缩包和备份实现。 -- 外部 Adapter 不导入 Oper。 -- 重点 Chain 每个完成至少 3 个垂直切片迁移。 -- `ChainBase` 调度可脱离真实 runtime 单测。 +- 本轮纳入阶段 3 的写端点不再直接持有数据库事务;当前机器基线中的 endpoint→Session、endpoint→Model、Application→DB 和目标 Adapter/Runtime→DB 边均为 0。后续只允许防止这些边重新引入,不再把历史边数量当作未完成任务。 +- PluginManager 不直接做市场、pip、压缩包和备份实现;外部 Adapter 不导入 Oper。 +- 重点 Chain 的垂直切片和 `ChainBase` 脱离真实 Runtime 的单测属于同一职责域内的持续细化,不再作为跨层拆分阻塞项。 -### 12.3 长期目标(阶段 6-7) +### 12.3 长期 ABI、性能与实现预算(阶段 6-7) -- 除明确第三方局部豁免外,自有 Python 模块 SCC 归零。 -- `app.agent.tools.factory` 出度从约 99 降至不高于 20。 +- 除明确第三方局部豁免外,自有 Python 模块 SCC 保持归零。 +- `app.agent.tools.factory` 出度从约 99 降至不高于 20,属于 Agent 同一职责域内的实现预算,不是本轮层间拆分的遗留边。 - 新 API endpoint 原则上不超过 80 行,新 Application 用例原则上不超过 150 行。 - 新插件常用能力只依赖 `app.sdk`/Host SPI;旧插件仍可运行。 - 兼容面有版本、命中数据、替代入口和机器可读清单。 @@ -1436,4 +1452,4 @@ done_when: [] --- -下一轮建议从阶段 6 开始,优先顺序为:**Application→Agent 的 3 条反向边 → Agent LLM/policy 自有 SCC → 消息与媒体服务器模块 SPI → 剩余 API/Session/Model 垂直切片**。每批仍按“契约快照、提取、旧入口委托、独立插件仓扫描、完整门禁”的顺序实施,不能因为阶段 0-5 已完成中期验收就删除 V3 兼容入口。 +本轮收口后,后续治理顺序调整为:**协议观测与性能预算 → 同一职责域内的垂直切片 → 兼容命中数据驱动的长期弃用评估**。不得以继续拆文件替代职责、事务和生命周期所有权迁移;每批仍按“契约快照、提取、旧入口委托、独立插件仓扫描、完整门禁”的顺序实施,且不得删除 V3 兼容入口。 diff --git a/docs/backend-module-refactor-compatibility.md b/docs/backend-module-refactor-compatibility.md index 47846f00e..727673101 100644 --- a/docs/backend-module-refactor-compatibility.md +++ b/docs/backend-module-refactor-compatibility.md @@ -123,7 +123,7 @@ Entrypoints / Plugins --> Application / Chain --> Domain + Ports --> Foundation | `utils.mixins` | 按能力拆分,配置重载部分归 `runtime` | 消除 mixin 对全局事件单例的导入期注册 | | `helper.redis/browser/doh/display/thread/package` 等 | `adapters/cache`、`adapters/network`、`adapters/system` 或 `runtime/thread.py` | 生命周期由 startup 装配,不在适配器内部反向获取管理器 | | `helper.module` | `foundation.reflection` | 只保留通用 Python 反射、模块发现与动态加载,不承担模块生命周期 | -| `helper.downloader/mediaserver/service` | `app.application` + `app.runtime.extensions.service_registry` | 媒体服务器身份/匹配规则与配置化服务发现统一归入 `application/mediaserver.py`,通用服务注册机制保持独立 | +| `helper.downloader/mediaserver/service` | `app.application` + `app.application.service` / `app.sdk.services` | 媒体服务器身份/匹配规则与配置化服务发现统一归入 application;旧 `app.runtime.extensions.service_registry` 由 `app/runtime/compat/manifest.py` 精确映射到 `app.sdk.services`,不在新模块复制旧导出 | | `helper.message/interaction` | `app.application.messaging` | 负责消息渲染、路由和交互,不承担配置化服务发现 | | `helper.notification` | `app.application.notification` | 通知模块发现依赖持久化配置,属于应用服务 | | `helper.webpush` | `app.api.endpoints.message` | Web Push 订阅和手动发送只服务消息 HTTP API,直接归入对应 endpoint | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 9210a54a3..6993f843d 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -430,11 +430,15 @@ policy. `app/db` therefore has no dependency on `app/domain`. | `app/startup/lifecycle/components.py` | Declarative normal/safe-mode lifecycle manifest, ordering and timeout budgets | | `app/runtime/extensions/module_manager.py` | Module discovery and lifecycle | | `app/runtime/extensions/plugin_manager.py` | Plugin discovery and lifecycle | +| `app/runtime/extensions/plugin/monitor.py` | Plugin file-change aggregation and monitor-thread lifecycle | | `app/runtime/extensions/plugin/projection.py` | Plugin commands, APIs, services, modules and actions projected from a running-registry snapshot | | `app/runtime/extensions/plugin/storage.py` | Injected plugin configuration/data persistence port; runtime code does not import DB Oper classes | | `app/application/plugin/catalog.py` | Plugin-market mapping, concurrent collection, generation merge and source/version deduplication | | `app/application/plugin/install.py` | Compatibility, package installation, reporting, installed-list persistence and runtime reload command | | `app/application/plugin/routes.py` | Dynamic plugin-route registry protocol; plugin response payloads remain raw unless the plugin chooses its own envelope | +| `app/application/plugin/runtime.py` | Plugin runtime port consumed by API, Agent and Workflow; the concrete `PluginManager` is registered only by startup | +| `app/application/module.py` | Host module runtime port consumed by entrypoints; the concrete `ModuleManager` is registered only by startup | +| `app/application/scheduling.py` | Scheduler runtime port consumed by API/Agent/application commands | | `app/application/server/report.py` | Server reporting use cases over injected local readers and transport callbacks | | `app/application/server/share.py` | Server sharing use cases over injected repositories and transport callbacks | | `app/adapters/external/plugin/client.py` | Plugin-market read adapter and cache-refresh boundary | @@ -469,4 +473,4 @@ imports, entrypoint (`api`/`agent`/`monitor`/`workflow`/`doctor`) imports of modules only through `run_module` dispatch), and downloader SDK (`qbittorrentapi`, `transmission_rpc`) imports inside `app/chain`. -*Last Updated: 2026-08-17* +*Last Updated: 2026-08-18* diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index 8bc0147bc..7d0129974 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -164,18 +164,39 @@ def collect_boundary_edges( """收集治理文档指定的当前越层边,供后续阶段逐项收缩。""" boundaries: dict[str, list[str]] = { "adapters_to_db": [], + "agent_to_db": [], + "api_to_db": [], "api_endpoints_to_db_models": [], "api_endpoints_to_sessions": [], "application_to_agent": [], + "application_to_db": [], + "chain_to_db": [], + "modules_to_db": [], + "monitor_to_db": [], "runtime_to_db": [], + "workflow_to_db": [], } for source, dependencies in graph.items(): for target in dependencies: edge = f"{source} -> {target}" if source.startswith("app.adapters") and target.startswith("app.db"): boundaries["adapters_to_db"].append(edge) + if source.startswith("app.agent") and target.startswith("app.db"): + boundaries["agent_to_db"].append(edge) + if source.startswith("app.api") and target.startswith("app.db"): + boundaries["api_to_db"].append(edge) + if source.startswith("app.application") and target.startswith("app.db"): + boundaries["application_to_db"].append(edge) + if source.startswith("app.chain") and target.startswith("app.db"): + boundaries["chain_to_db"].append(edge) + if source.startswith("app.modules") and target.startswith("app.db"): + boundaries["modules_to_db"].append(edge) + if source.startswith("app.monitor") and target.startswith("app.db"): + boundaries["monitor_to_db"].append(edge) if source.startswith("app.runtime") and target.startswith("app.db"): boundaries["runtime_to_db"].append(edge) + if source.startswith("app.workflow") and target.startswith("app.db"): + boundaries["workflow_to_db"].append(edge) if source.startswith("app.api.endpoints") and target.startswith( "app.db.models" ): diff --git a/tests/conftest.py b/tests/conftest.py index 08958f3f1..49392c187 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,126 @@ from app.testing.network_guard import block_real_network # noqa: E402,F401 @pytest.fixture(autouse=True) def configure_plugin_system_services(): """为绕过完整启动流程的单元测试装配真实插件系统适配器。""" + from app.adapters.web.security.access import configure_token_codec + from app.application.security.token import ( + create_access_token, + decode_access_token, + ) + from app.api.data import configure_api_data_ports + from app.application.configuration import SystemConfigService, configure_system_config + from app.application.service import configure_service_directory + from app.db.session import get_async_db, get_db + from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork + from app.db.oper.systemconfig import SystemConfigOper + + configure_token_codec(create_access_token, decode_access_token) + configure_system_config(SystemConfigService(repository=SystemConfigOper())) + from app.application.chain.data import configure_chain_data_ports + from app.application.plugin.runtime import configure_plugin_runtime + from app.application.module import configure_module_runtime + from app.application.chain.context import ( + ChainRuntimeContext, + configure_chain_runtime_context_provider, + ) + from app.application.messaging.message import MessageHelper, MessageQueueManager + from app.runtime.cache import AsyncFileCache, FileCache + from app.runtime.events import EventManager + from app.runtime.extensions.module_manager import ModuleManager + from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher + from app.runtime.extensions.plugin_manager import PluginManager + from app.runtime.extensions.service_config import ServiceConfigHelper + configure_service_directory( + configs=ServiceConfigHelper.get_configs, + modules=lambda module_type: ModuleManager().get_running_type_modules(module_type), + ) + configure_plugin_runtime(lambda: PluginManager()) + configure_module_runtime(lambda: ModuleManager()) + from app.application.site.query import SiteQueryService, configure_site_query_service + from app.application.site.health import SiteHealthService, configure_site_health_service + from app.application.workflow import WorkflowQueryService, configure_workflow_query + from app.application.agentdata import configure_agent_data_ports + from app.db.oper.agentchat import AgentChatOper + from app.db.oper.downloadfailure import DownloadFailureOper + from app.db.oper.downloadhistory import DownloadHistoryOper + from app.db.oper.mediaserver import MediaServerOper + from app.db.oper.site import SiteOper + from app.db.oper.subscribe import SubscribeOper + from app.db.oper.subscribehistory import SubscribeHistoryOper + from app.db.oper.transferhistory import TransferHistoryOper + from app.db.oper.transferpending import TransferPendingOper + from app.db.oper.user import UserOper + from app.db.oper.workflow import WorkflowOper + from app.db.oper.message import MessageOper + from app.db.oper.passkey import PassKeyOper + + configure_api_data_ports( + sync_session=get_db, + async_session=get_async_db, + repositories={ + "agent_chat": AgentChatOper, + "download_history": DownloadHistoryOper, + "media_server": MediaServerOper, + "message": MessageOper, + "passkey": PassKeyOper, + "site": SiteOper, + "subscribe": SubscribeOper, + "subscribe_history": SubscribeHistoryOper, + "transfer_history": TransferHistoryOper, + "user": UserOper, + "workflow": WorkflowOper, + }, + standalone={ + "passkey": PassKeyOper, + "system_config": SystemConfigOper, + "user": UserOper, + }, + unit_of_work={ + "async": SqlAlchemyAsyncUnitOfWork, + "sync": SqlAlchemyUnitOfWork, + }, + ) + + configure_chain_data_ports( + site=lambda: SiteOper(), + subscribe=lambda: SubscribeOper(), + workflow=lambda: WorkflowOper(), + download_history=lambda: DownloadHistoryOper(), + transfer_history=lambda: TransferHistoryOper(), + transfer_pending=lambda: TransferPendingOper(), + media_server=lambda: MediaServerOper(), + download_failure=lambda: DownloadFailureOper(), + user=lambda: UserOper(), + ) + configure_chain_runtime_context_provider(lambda: ChainRuntimeContext( + module_manager=ModuleManager(), + plugin_manager=PluginManager(), + event_manager=EventManager(), + message_oper=MessageOper(), + message_helper=MessageHelper(), + file_cache=FileCache(), + async_file_cache=AsyncFileCache(), + message_queue_factory=lambda callback: MessageQueueManager( + send_callback=callback + ), + module_dispatcher_factory=ModuleInvocationDispatcher, + )) + configure_site_query_service(SiteQueryService(repository=SiteOper())) + configure_site_health_service(SiteHealthService(repository=SiteOper())) + configure_workflow_query(WorkflowQueryService(repository=WorkflowOper())) + from app.db.oper.agenttask import AgentTaskOper + from app.db.oper.plugindata import PluginDataOper + configure_agent_data_ports( + agent_chat=lambda: AgentChatOper(), + agent_task=lambda: AgentTaskOper(), + user=lambda: UserOper(), + site=lambda: SiteOper(), + subscribe=lambda: SubscribeOper(), + subscribe_history=lambda: SubscribeHistoryOper(), + transfer_history=lambda: TransferHistoryOper(), + download_history=lambda: DownloadHistoryOper(), + workflow=lambda: WorkflowOper(), + plugin_data=lambda: PluginDataOper(), + ) from app.adapters.external.market import ( PluginHelper, VERSION_BACKWARD_COMPATIBLE_FLAGS, @@ -46,6 +166,13 @@ def configure_plugin_system_services(): ), frozen=lambda: False, )) + from app.agent.skills.registry import SkillHelper + from app.agent.llm.gateway import register_llm_provider_runtime + from app.agent.llm.provider import LLMProviderManager + from app.application.messaging.skill import register_skill_catalog_provider + + register_skill_catalog_provider(lambda: SkillHelper()) + register_llm_provider_runtime(lambda: LLMProviderManager()) yield reset_plugin_system() diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e84633cdd..0f7721593 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1,75 +1,20 @@ { "boundary_edges": { "adapters_to_db": [], - "api_endpoints_to_db_models": [ - "app.api.endpoints.agent -> app.db.models", - "app.api.endpoints.agent -> app.db.models.agentchat", - "app.api.endpoints.auth -> app.db.models", - "app.api.endpoints.auth -> app.db.models.passkey", - "app.api.endpoints.auth -> app.db.models.user", - "app.api.endpoints.dashboard -> app.db.models", - "app.api.endpoints.dashboard -> app.db.models.transferhistory", - "app.api.endpoints.download -> app.db.models", - "app.api.endpoints.download -> app.db.models.user", - "app.api.endpoints.history -> app.db.models", - "app.api.endpoints.history -> app.db.models.downloadhistory", - "app.api.endpoints.history -> app.db.models.transferhistory", - "app.api.endpoints.llm -> app.db.models", - "app.api.endpoints.media -> app.db.models", - "app.api.endpoints.mediaserver -> app.db.models", - "app.api.endpoints.message -> app.db.models", - "app.api.endpoints.mfa -> app.db.models", - "app.api.endpoints.mfa -> app.db.models.passkey", - "app.api.endpoints.mfa -> app.db.models.user", - "app.api.endpoints.music -> app.db.models", - "app.api.endpoints.music -> app.db.models.user", - "app.api.endpoints.notification -> app.db.models", - "app.api.endpoints.plugin -> app.db.models", - "app.api.endpoints.site -> app.db.models", - "app.api.endpoints.site -> app.db.models.site", - "app.api.endpoints.site -> app.db.models.siteicon", - "app.api.endpoints.site -> app.db.models.sitestatistic", - "app.api.endpoints.site -> app.db.models.siteuserdata", - "app.api.endpoints.storage -> app.db.models", - "app.api.endpoints.subscribe -> app.db.models", - "app.api.endpoints.subscribe -> app.db.models.subscribe", - "app.api.endpoints.subscribe -> app.db.models.subscribehistory", - "app.api.endpoints.subscribe -> app.db.models.user", - "app.api.endpoints.system -> app.db.models", - "app.api.endpoints.tmdb -> app.db.models", - "app.api.endpoints.tmdb -> app.db.models.user", - "app.api.endpoints.torrent -> app.db.models", - "app.api.endpoints.transfer -> app.db.models", - "app.api.endpoints.transfer -> app.db.models.transferhistory", - "app.api.endpoints.user -> app.db.models", - "app.api.endpoints.user -> app.db.models.user", - "app.api.endpoints.workflow -> app.db.models" - ], - "api_endpoints_to_sessions": [ - "app.api.endpoints.agent -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.dashboard -> sqlalchemy.orm.Session", - "app.api.endpoints.history -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.history -> sqlalchemy.orm.Session", - "app.api.endpoints.mediaserver -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.message -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.mfa -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.site -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.site -> sqlalchemy.orm.Session", - "app.api.endpoints.subscribe -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.subscribe -> sqlalchemy.orm.Session", - "app.api.endpoints.transfer -> sqlalchemy.orm.Session", - "app.api.endpoints.user -> sqlalchemy.ext.asyncio.AsyncSession", - "app.api.endpoints.workflow -> sqlalchemy.ext.asyncio.AsyncSession" - ], - "application_to_agent": [ - "app.application.messaging.skill -> app.agent", - "app.application.messaging.skill -> app.agent.skills", - "app.application.messaging.skill -> app.agent.skills.registry" - ], - "runtime_to_db": [] + "agent_to_db": [], + "api_endpoints_to_db_models": [], + "api_endpoints_to_sessions": [], + "api_to_db": [], + "application_to_agent": [], + "application_to_db": [], + "chain_to_db": [], + "modules_to_db": [], + "monitor_to_db": [], + "runtime_to_db": [], + "workflow_to_db": [] }, - "edge_count": 6071, - "edge_sha256": "3b582e5a31f143d33056f97803f81b7df3452811c5ca64c50555d3479ea0c340", + "edge_count": 6021, + "edge_sha256": "1dbba158e94cf7f54d596c35d7cf026700273643fcc86b1e6b0adc4150323027", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -170,6 +115,8 @@ "app.adapters.system.fsproxy -> app.runtime", "app.adapters.system.fsproxy -> app.runtime.config", "app.adapters.system.fsproxy -> app.runtime.log", + "app.adapters.system.host -> app.foundation", + "app.adapters.system.host -> app.foundation.environment", "app.adapters.system.host -> app.schemas", "app.adapters.system.host -> app.schemas.dashboard", "app.adapters.system.plugin.dependency -> app.adapters", @@ -197,6 +144,12 @@ "app.adapters.system.rust -> app.runtime", "app.adapters.system.rust -> app.runtime.config", "app.adapters.system.rust -> app.runtime.log", + "app.adapters.web.security.access -> app.runtime", + "app.adapters.web.security.access -> app.runtime.cache", + "app.adapters.web.security.access -> app.runtime.config", + "app.adapters.web.security.access -> app.runtime.log", + "app.adapters.web.security.access -> app.schemas", + "app.adapters.web.security.access -> app.schemas.token", "app.agent.callback -> app.agent", "app.agent.callback -> app.agent.policy", "app.agent.callback -> app.chain", @@ -215,10 +168,6 @@ "app.agent.capabilities.adapter -> app.runtime.config", "app.agent.contracts -> app.schemas", "app.agent.contracts -> app.schemas.types", - "app.agent.llm -> app.agent", - "app.agent.llm -> app.agent.llm.capability", - "app.agent.llm -> app.agent.llm.helper", - "app.agent.llm -> app.agent.llm.provider", "app.agent.llm.capability -> app.adapters", "app.agent.llm.capability -> app.adapters.network", "app.agent.llm.capability -> app.adapters.network.http", @@ -228,14 +177,14 @@ "app.agent.llm.capability -> app.runtime", "app.agent.llm.capability -> app.runtime.config", "app.agent.llm.capability -> app.runtime.extensions", - "app.agent.llm.capability -> app.runtime.extensions.service_registry", + "app.agent.llm.capability -> app.runtime.extensions.service_config", "app.agent.llm.capability -> app.runtime.log", "app.agent.llm.capability -> app.schemas", "app.agent.llm.capability -> app.schemas.notification", "app.agent.llm.capability -> app.schemas.types", "app.agent.llm.helper -> app.agent", "app.agent.llm.helper -> app.agent.llm", - "app.agent.llm.helper -> app.agent.llm.provider", + "app.agent.llm.helper -> app.agent.llm.gateway", "app.agent.llm.helper -> app.agent.llm.server_tools", "app.agent.llm.helper -> app.runtime", "app.agent.llm.helper -> app.runtime.config", @@ -243,9 +192,8 @@ "app.agent.llm.provider -> app.agent", "app.agent.llm.provider -> app.agent.llm", "app.agent.llm.provider -> app.agent.llm.helper", - "app.agent.llm.provider -> app.db", - "app.agent.llm.provider -> app.db.oper", - "app.agent.llm.provider -> app.db.oper.systemconfig", + "app.agent.llm.provider -> app.application", + "app.agent.llm.provider -> app.application.configuration", "app.agent.llm.provider -> app.foundation", "app.agent.llm.provider -> app.foundation.singleton", "app.agent.llm.provider -> app.runtime", @@ -256,17 +204,15 @@ "app.agent.mcp -> app.adapters", "app.agent.mcp -> app.adapters.network", "app.agent.mcp -> app.adapters.network.http", - "app.agent.mcp -> app.db", - "app.agent.mcp -> app.db.oper", - "app.agent.mcp -> app.db.oper.systemconfig", + "app.agent.mcp -> app.application", + "app.agent.mcp -> app.application.configuration", "app.agent.mcp -> app.runtime", "app.agent.mcp -> app.runtime.log", "app.agent.mcp -> app.schemas", "app.agent.mcp -> app.schemas.agent", "app.agent.mcp -> app.schemas.types", - "app.agent.memory -> app.db", - "app.agent.memory -> app.db.oper", - "app.agent.memory -> app.db.oper.agentchat", + "app.agent.memory -> app.application", + "app.agent.memory -> app.application.agentdata", "app.agent.memory -> app.runtime", "app.agent.memory -> app.runtime.config", "app.agent.memory -> app.runtime.log", @@ -364,31 +310,23 @@ "app.agent.orchestrator -> app.agent.tools.impl", "app.agent.orchestrator -> app.agent.tools.impl.mcp", "app.agent.orchestrator -> app.agent.tools.impl.query_system_settings", + "app.agent.orchestrator -> app.application", + "app.agent.orchestrator -> app.application.agentdata", + "app.agent.orchestrator -> app.application.plugin", + "app.agent.orchestrator -> app.application.plugin.runtime", "app.agent.orchestrator -> app.chain", "app.agent.orchestrator -> app.chain.agent", - "app.agent.orchestrator -> app.db", - "app.agent.orchestrator -> app.db.oper", - "app.agent.orchestrator -> app.db.oper.agentchat", - "app.agent.orchestrator -> app.db.oper.agenttask", - "app.agent.orchestrator -> app.db.oper.user", "app.agent.orchestrator -> app.foundation", "app.agent.orchestrator -> app.foundation.identity", "app.agent.orchestrator -> app.runtime", "app.agent.orchestrator -> app.runtime.config", "app.agent.orchestrator -> app.runtime.events", - "app.agent.orchestrator -> app.runtime.extensions", - "app.agent.orchestrator -> app.runtime.extensions.plugin_manager", "app.agent.orchestrator -> app.runtime.log", "app.agent.orchestrator -> app.schemas", "app.agent.orchestrator -> app.schemas.event", "app.agent.orchestrator -> app.schemas.message", "app.agent.orchestrator -> app.schemas.notification", "app.agent.orchestrator -> app.schemas.types", - "app.agent.policy -> app.agent", - "app.agent.policy -> app.agent.policy.contracts", - "app.agent.policy -> app.agent.policy.orchestrator", - "app.agent.policy -> app.agent.policy.registry", - "app.agent.policy -> app.agent.policy.sanitizer", "app.agent.policy.orchestrator -> app.agent", "app.agent.policy.orchestrator -> app.agent.policy", "app.agent.policy.orchestrator -> app.agent.policy.contracts", @@ -451,7 +389,7 @@ "app.agent.tools.base -> app.runtime", "app.agent.tools.base -> app.runtime.config", "app.agent.tools.base -> app.runtime.extensions", - "app.agent.tools.base -> app.runtime.extensions.service_registry", + "app.agent.tools.base -> app.runtime.extensions.service_config", "app.agent.tools.base -> app.runtime.log", "app.agent.tools.base -> app.schemas", "app.agent.tools.base -> app.schemas.message", @@ -550,19 +488,18 @@ "app.agent.tools.factory -> app.agent.tools.impl.update_subscribe", "app.agent.tools.factory -> app.agent.tools.impl.update_system_settings", "app.agent.tools.factory -> app.agent.tools.impl.write_file", + "app.agent.tools.factory -> app.application", + "app.agent.tools.factory -> app.application.plugin", + "app.agent.tools.factory -> app.application.plugin.runtime", "app.agent.tools.factory -> app.runtime", - "app.agent.tools.factory -> app.runtime.extensions", - "app.agent.tools.factory -> app.runtime.extensions.plugin_manager", "app.agent.tools.factory -> app.runtime.log", "app.agent.tools.factory -> app.schemas", "app.agent.tools.factory -> app.schemas.notification", "app.agent.tools.factory -> app.schemas.types", "app.agent.tools.impl._filter_rule_utils -> app.application", + "app.agent.tools.impl._filter_rule_utils -> app.application.agentdata", + "app.agent.tools.impl._filter_rule_utils -> app.application.configuration", "app.agent.tools.impl._filter_rule_utils -> app.application.rules", - "app.agent.tools.impl._filter_rule_utils -> app.db", - "app.agent.tools.impl._filter_rule_utils -> app.db.oper", - "app.agent.tools.impl._filter_rule_utils -> app.db.oper.subscribe", - "app.agent.tools.impl._filter_rule_utils -> app.db.oper.systemconfig", "app.agent.tools.impl._filter_rule_utils -> app.runtime", "app.agent.tools.impl._filter_rule_utils -> app.runtime.events", "app.agent.tools.impl._filter_rule_utils -> app.schemas", @@ -586,17 +523,14 @@ "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools.base", "app.agent.tools.impl._plugin_tool_utils -> app.application", "app.agent.tools.impl._plugin_tool_utils -> app.application.commands", + "app.agent.tools.impl._plugin_tool_utils -> app.application.configuration", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install", + "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugins", "app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling", - "app.agent.tools.impl._plugin_tool_utils -> app.db", - "app.agent.tools.impl._plugin_tool_utils -> app.db.oper", - "app.agent.tools.impl._plugin_tool_utils -> app.db.oper.systemconfig", "app.agent.tools.impl._plugin_tool_utils -> app.runtime", "app.agent.tools.impl._plugin_tool_utils -> app.runtime.config", - "app.agent.tools.impl._plugin_tool_utils -> app.runtime.extensions", - "app.agent.tools.impl._plugin_tool_utils -> app.runtime.extensions.plugin_manager", "app.agent.tools.impl._plugin_tool_utils -> app.schemas", "app.agent.tools.impl._plugin_tool_utils -> app.schemas.types", "app.agent.tools.impl._system_setting_utils -> app.agent", @@ -639,14 +573,12 @@ "app.agent.tools.impl.add_download_tasks -> app.agent.tools.base", "app.agent.tools.impl.add_download_tasks -> app.agent.tools.tags", "app.agent.tools.impl.add_download_tasks -> app.application", + "app.agent.tools.impl.add_download_tasks -> app.application.agentdata", "app.agent.tools.impl.add_download_tasks -> app.application.directory", "app.agent.tools.impl.add_download_tasks -> app.chain", "app.agent.tools.impl.add_download_tasks -> app.chain.download", "app.agent.tools.impl.add_download_tasks -> app.chain.media", "app.agent.tools.impl.add_download_tasks -> app.chain.search", - "app.agent.tools.impl.add_download_tasks -> app.db", - "app.agent.tools.impl.add_download_tasks -> app.db.oper", - "app.agent.tools.impl.add_download_tasks -> app.db.oper.site", "app.agent.tools.impl.add_download_tasks -> app.domain", "app.agent.tools.impl.add_download_tasks -> app.domain.context", "app.agent.tools.impl.add_download_tasks -> app.domain.metainfo", @@ -671,11 +603,10 @@ "app.agent.tools.impl.add_subscribe -> app.agent.tools", "app.agent.tools.impl.add_subscribe -> app.agent.tools.base", "app.agent.tools.impl.add_subscribe -> app.agent.tools.tags", + "app.agent.tools.impl.add_subscribe -> app.application", + "app.agent.tools.impl.add_subscribe -> app.application.agentdata", "app.agent.tools.impl.add_subscribe -> app.chain", "app.agent.tools.impl.add_subscribe -> app.chain.subscribe", - "app.agent.tools.impl.add_subscribe -> app.db", - "app.agent.tools.impl.add_subscribe -> app.db.oper", - "app.agent.tools.impl.add_subscribe -> app.db.oper.user", "app.agent.tools.impl.add_subscribe -> app.domain", "app.agent.tools.impl.add_subscribe -> app.domain.media", "app.agent.tools.impl.add_subscribe -> app.runtime", @@ -717,11 +648,8 @@ "app.agent.tools.impl.create_agent_task -> app.agent.tools.base", "app.agent.tools.impl.create_agent_task -> app.agent.tools.tags", "app.agent.tools.impl.create_agent_task -> app.application", + "app.agent.tools.impl.create_agent_task -> app.application.agentdata", "app.agent.tools.impl.create_agent_task -> app.application.scheduling", - "app.agent.tools.impl.create_agent_task -> app.db", - "app.agent.tools.impl.create_agent_task -> app.db.oper", - "app.agent.tools.impl.create_agent_task -> app.db.oper.agentchat", - "app.agent.tools.impl.create_agent_task -> app.db.oper.agenttask", "app.agent.tools.impl.create_agent_task -> app.runtime", "app.agent.tools.impl.create_agent_task -> app.runtime.config", "app.agent.tools.impl.create_agent_task -> app.runtime.scheduling", @@ -730,10 +658,8 @@ "app.agent.tools.impl.delete_agent_task -> app.agent.tools.base", "app.agent.tools.impl.delete_agent_task -> app.agent.tools.tags", "app.agent.tools.impl.delete_agent_task -> app.application", + "app.agent.tools.impl.delete_agent_task -> app.application.agentdata", "app.agent.tools.impl.delete_agent_task -> app.application.scheduling", - "app.agent.tools.impl.delete_agent_task -> app.db", - "app.agent.tools.impl.delete_agent_task -> app.db.oper", - "app.agent.tools.impl.delete_agent_task -> app.db.oper.agenttask", "app.agent.tools.impl.delete_custom_filter_rule -> app.agent", "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools", "app.agent.tools.impl.delete_custom_filter_rule -> app.agent.tools.base", @@ -748,9 +674,8 @@ "app.agent.tools.impl.delete_download_history -> app.agent.tools", "app.agent.tools.impl.delete_download_history -> app.agent.tools.base", "app.agent.tools.impl.delete_download_history -> app.agent.tools.tags", - "app.agent.tools.impl.delete_download_history -> app.db", - "app.agent.tools.impl.delete_download_history -> app.db.oper", - "app.agent.tools.impl.delete_download_history -> app.db.oper.downloadhistory", + "app.agent.tools.impl.delete_download_history -> app.application", + "app.agent.tools.impl.delete_download_history -> app.application.agentdata", "app.agent.tools.impl.delete_download_history -> app.runtime", "app.agent.tools.impl.delete_download_history -> app.runtime.log", "app.agent.tools.impl.delete_download_tasks -> app.agent", @@ -778,9 +703,8 @@ "app.agent.tools.impl.delete_subscribe -> app.agent.tools", "app.agent.tools.impl.delete_subscribe -> app.agent.tools.base", "app.agent.tools.impl.delete_subscribe -> app.agent.tools.tags", - "app.agent.tools.impl.delete_subscribe -> app.db", - "app.agent.tools.impl.delete_subscribe -> app.db.oper", - "app.agent.tools.impl.delete_subscribe -> app.db.oper.subscribe", + "app.agent.tools.impl.delete_subscribe -> app.application", + "app.agent.tools.impl.delete_subscribe -> app.application.agentdata", "app.agent.tools.impl.delete_subscribe -> app.runtime", "app.agent.tools.impl.delete_subscribe -> app.runtime.events", "app.agent.tools.impl.delete_subscribe -> app.runtime.log", @@ -790,11 +714,10 @@ "app.agent.tools.impl.delete_transfer_history -> app.agent.tools", "app.agent.tools.impl.delete_transfer_history -> app.agent.tools.base", "app.agent.tools.impl.delete_transfer_history -> app.agent.tools.tags", + "app.agent.tools.impl.delete_transfer_history -> app.application", + "app.agent.tools.impl.delete_transfer_history -> app.application.agentdata", "app.agent.tools.impl.delete_transfer_history -> app.chain", "app.agent.tools.impl.delete_transfer_history -> app.chain.storage", - "app.agent.tools.impl.delete_transfer_history -> app.db", - "app.agent.tools.impl.delete_transfer_history -> app.db.oper", - "app.agent.tools.impl.delete_transfer_history -> app.db.oper.transferhistory", "app.agent.tools.impl.delete_transfer_history -> app.runtime", "app.agent.tools.impl.delete_transfer_history -> app.runtime.log", "app.agent.tools.impl.delete_transfer_history -> app.schemas", @@ -880,10 +803,8 @@ "app.agent.tools.impl.query_agent_tasks -> app.agent.tools.base", "app.agent.tools.impl.query_agent_tasks -> app.agent.tools.tags", "app.agent.tools.impl.query_agent_tasks -> app.application", + "app.agent.tools.impl.query_agent_tasks -> app.application.agentdata", "app.agent.tools.impl.query_agent_tasks -> app.application.scheduling", - "app.agent.tools.impl.query_agent_tasks -> app.db", - "app.agent.tools.impl.query_agent_tasks -> app.db.oper", - "app.agent.tools.impl.query_agent_tasks -> app.db.oper.agenttask", "app.agent.tools.impl.query_agent_tasks -> app.runtime", "app.agent.tools.impl.query_agent_tasks -> app.runtime.config", "app.agent.tools.impl.query_builtin_filter_rules -> app.agent", @@ -906,9 +827,8 @@ "app.agent.tools.impl.query_custom_identifiers -> app.agent.tools", "app.agent.tools.impl.query_custom_identifiers -> app.agent.tools.base", "app.agent.tools.impl.query_custom_identifiers -> app.agent.tools.tags", - "app.agent.tools.impl.query_custom_identifiers -> app.db", - "app.agent.tools.impl.query_custom_identifiers -> app.db.oper", - "app.agent.tools.impl.query_custom_identifiers -> app.db.oper.systemconfig", + "app.agent.tools.impl.query_custom_identifiers -> app.application", + "app.agent.tools.impl.query_custom_identifiers -> app.application.configuration", "app.agent.tools.impl.query_custom_identifiers -> app.runtime", "app.agent.tools.impl.query_custom_identifiers -> app.runtime.log", "app.agent.tools.impl.query_custom_identifiers -> app.schemas", @@ -932,11 +852,10 @@ "app.agent.tools.impl.query_download_tasks -> app.agent.tools", "app.agent.tools.impl.query_download_tasks -> app.agent.tools.base", "app.agent.tools.impl.query_download_tasks -> app.agent.tools.tags", + "app.agent.tools.impl.query_download_tasks -> app.application", + "app.agent.tools.impl.query_download_tasks -> app.application.agentdata", "app.agent.tools.impl.query_download_tasks -> app.chain", "app.agent.tools.impl.query_download_tasks -> app.chain.download", - "app.agent.tools.impl.query_download_tasks -> app.db", - "app.agent.tools.impl.query_download_tasks -> app.db.oper", - "app.agent.tools.impl.query_download_tasks -> app.db.oper.downloadhistory", "app.agent.tools.impl.query_download_tasks -> app.runtime", "app.agent.tools.impl.query_download_tasks -> app.runtime.log", "app.agent.tools.impl.query_download_tasks -> app.schemas", @@ -946,9 +865,8 @@ "app.agent.tools.impl.query_downloaders -> app.agent.tools", "app.agent.tools.impl.query_downloaders -> app.agent.tools.base", "app.agent.tools.impl.query_downloaders -> app.agent.tools.tags", - "app.agent.tools.impl.query_downloaders -> app.db", - "app.agent.tools.impl.query_downloaders -> app.db.oper", - "app.agent.tools.impl.query_downloaders -> app.db.oper.systemconfig", + "app.agent.tools.impl.query_downloaders -> app.application", + "app.agent.tools.impl.query_downloaders -> app.application.configuration", "app.agent.tools.impl.query_downloaders -> app.runtime", "app.agent.tools.impl.query_downloaders -> app.runtime.log", "app.agent.tools.impl.query_downloaders -> app.schemas", @@ -991,7 +909,7 @@ "app.agent.tools.impl.query_library_latest -> app.chain.mediaserver", "app.agent.tools.impl.query_library_latest -> app.runtime", "app.agent.tools.impl.query_library_latest -> app.runtime.extensions", - "app.agent.tools.impl.query_library_latest -> app.runtime.extensions.service_registry", + "app.agent.tools.impl.query_library_latest -> app.runtime.extensions.service_config", "app.agent.tools.impl.query_library_latest -> app.runtime.log", "app.agent.tools.impl.query_market_plugins -> app.agent", "app.agent.tools.impl.query_market_plugins -> app.agent.tools", @@ -1026,9 +944,10 @@ "app.agent.tools.impl.query_plugin_capabilities -> app.agent.tools", "app.agent.tools.impl.query_plugin_capabilities -> app.agent.tools.base", "app.agent.tools.impl.query_plugin_capabilities -> app.agent.tools.tags", + "app.agent.tools.impl.query_plugin_capabilities -> app.application", + "app.agent.tools.impl.query_plugin_capabilities -> app.application.plugin", + "app.agent.tools.impl.query_plugin_capabilities -> app.application.plugin.runtime", "app.agent.tools.impl.query_plugin_capabilities -> app.runtime", - "app.agent.tools.impl.query_plugin_capabilities -> app.runtime.extensions", - "app.agent.tools.impl.query_plugin_capabilities -> app.runtime.extensions.plugin_manager", "app.agent.tools.impl.query_plugin_capabilities -> app.runtime.log", "app.agent.tools.impl.query_plugin_config -> app.agent", "app.agent.tools.impl.query_plugin_config -> app.agent.tools", @@ -1036,9 +955,10 @@ "app.agent.tools.impl.query_plugin_config -> app.agent.tools.impl", "app.agent.tools.impl.query_plugin_config -> app.agent.tools.impl._plugin_tool_utils", "app.agent.tools.impl.query_plugin_config -> app.agent.tools.tags", + "app.agent.tools.impl.query_plugin_config -> app.application", + "app.agent.tools.impl.query_plugin_config -> app.application.plugin", + "app.agent.tools.impl.query_plugin_config -> app.application.plugin.runtime", "app.agent.tools.impl.query_plugin_config -> app.runtime", - "app.agent.tools.impl.query_plugin_config -> app.runtime.extensions", - "app.agent.tools.impl.query_plugin_config -> app.runtime.extensions.plugin_manager", "app.agent.tools.impl.query_plugin_config -> app.runtime.log", "app.agent.tools.impl.query_plugin_data -> app.agent", "app.agent.tools.impl.query_plugin_data -> app.agent.tools", @@ -1046,9 +966,8 @@ "app.agent.tools.impl.query_plugin_data -> app.agent.tools.impl", "app.agent.tools.impl.query_plugin_data -> app.agent.tools.impl._plugin_tool_utils", "app.agent.tools.impl.query_plugin_data -> app.agent.tools.tags", - "app.agent.tools.impl.query_plugin_data -> app.db", - "app.agent.tools.impl.query_plugin_data -> app.db.oper", - "app.agent.tools.impl.query_plugin_data -> app.db.oper.plugindata", + "app.agent.tools.impl.query_plugin_data -> app.application", + "app.agent.tools.impl.query_plugin_data -> app.application.agentdata", "app.agent.tools.impl.query_plugin_data -> app.runtime", "app.agent.tools.impl.query_plugin_data -> app.runtime.log", "app.agent.tools.impl.query_popular_subscribes -> app.adapters", @@ -1085,27 +1004,24 @@ "app.agent.tools.impl.query_site_userdata -> app.agent.tools", "app.agent.tools.impl.query_site_userdata -> app.agent.tools.base", "app.agent.tools.impl.query_site_userdata -> app.agent.tools.tags", - "app.agent.tools.impl.query_site_userdata -> app.db", - "app.agent.tools.impl.query_site_userdata -> app.db.oper", - "app.agent.tools.impl.query_site_userdata -> app.db.oper.site", + "app.agent.tools.impl.query_site_userdata -> app.application", + "app.agent.tools.impl.query_site_userdata -> app.application.agentdata", "app.agent.tools.impl.query_site_userdata -> app.runtime", "app.agent.tools.impl.query_site_userdata -> app.runtime.log", "app.agent.tools.impl.query_sites -> app.agent", "app.agent.tools.impl.query_sites -> app.agent.tools", "app.agent.tools.impl.query_sites -> app.agent.tools.base", "app.agent.tools.impl.query_sites -> app.agent.tools.tags", - "app.agent.tools.impl.query_sites -> app.db", - "app.agent.tools.impl.query_sites -> app.db.oper", - "app.agent.tools.impl.query_sites -> app.db.oper.site", + "app.agent.tools.impl.query_sites -> app.application", + "app.agent.tools.impl.query_sites -> app.application.agentdata", "app.agent.tools.impl.query_sites -> app.runtime", "app.agent.tools.impl.query_sites -> app.runtime.log", "app.agent.tools.impl.query_subscribe_history -> app.agent", "app.agent.tools.impl.query_subscribe_history -> app.agent.tools", "app.agent.tools.impl.query_subscribe_history -> app.agent.tools.base", "app.agent.tools.impl.query_subscribe_history -> app.agent.tools.tags", - "app.agent.tools.impl.query_subscribe_history -> app.db", - "app.agent.tools.impl.query_subscribe_history -> app.db.oper", - "app.agent.tools.impl.query_subscribe_history -> app.db.oper.subscribehistory", + "app.agent.tools.impl.query_subscribe_history -> app.application", + "app.agent.tools.impl.query_subscribe_history -> app.application.agentdata", "app.agent.tools.impl.query_subscribe_history -> app.domain", "app.agent.tools.impl.query_subscribe_history -> app.domain.media", "app.agent.tools.impl.query_subscribe_history -> app.runtime", @@ -1129,9 +1045,8 @@ "app.agent.tools.impl.query_subscribes -> app.agent.tools", "app.agent.tools.impl.query_subscribes -> app.agent.tools.base", "app.agent.tools.impl.query_subscribes -> app.agent.tools.tags", - "app.agent.tools.impl.query_subscribes -> app.db", - "app.agent.tools.impl.query_subscribes -> app.db.oper", - "app.agent.tools.impl.query_subscribes -> app.db.oper.subscribe", + "app.agent.tools.impl.query_subscribes -> app.application", + "app.agent.tools.impl.query_subscribes -> app.application.agentdata", "app.agent.tools.impl.query_subscribes -> app.domain", "app.agent.tools.impl.query_subscribes -> app.domain.media", "app.agent.tools.impl.query_subscribes -> app.runtime", @@ -1145,9 +1060,8 @@ "app.agent.tools.impl.query_system_settings -> app.agent.tools.impl", "app.agent.tools.impl.query_system_settings -> app.agent.tools.impl._system_setting_utils", "app.agent.tools.impl.query_system_settings -> app.agent.tools.tags", - "app.agent.tools.impl.query_system_settings -> app.db", - "app.agent.tools.impl.query_system_settings -> app.db.oper", - "app.agent.tools.impl.query_system_settings -> app.db.oper.systemconfig", + "app.agent.tools.impl.query_system_settings -> app.application", + "app.agent.tools.impl.query_system_settings -> app.application.configuration", "app.agent.tools.impl.query_system_settings -> app.runtime", "app.agent.tools.impl.query_system_settings -> app.runtime.config", "app.agent.tools.impl.query_system_settings -> app.runtime.log", @@ -1155,9 +1069,8 @@ "app.agent.tools.impl.query_transfer_history -> app.agent.tools", "app.agent.tools.impl.query_transfer_history -> app.agent.tools.base", "app.agent.tools.impl.query_transfer_history -> app.agent.tools.tags", - "app.agent.tools.impl.query_transfer_history -> app.db", - "app.agent.tools.impl.query_transfer_history -> app.db.oper", - "app.agent.tools.impl.query_transfer_history -> app.db.oper.transferhistory", + "app.agent.tools.impl.query_transfer_history -> app.application", + "app.agent.tools.impl.query_transfer_history -> app.application.agentdata", "app.agent.tools.impl.query_transfer_history -> app.foundation", "app.agent.tools.impl.query_transfer_history -> app.foundation.text", "app.agent.tools.impl.query_transfer_history -> app.runtime", @@ -1168,9 +1081,8 @@ "app.agent.tools.impl.query_workflows -> app.agent.tools", "app.agent.tools.impl.query_workflows -> app.agent.tools.base", "app.agent.tools.impl.query_workflows -> app.agent.tools.tags", - "app.agent.tools.impl.query_workflows -> app.db", - "app.agent.tools.impl.query_workflows -> app.db.oper", - "app.agent.tools.impl.query_workflows -> app.db.oper.workflow", + "app.agent.tools.impl.query_workflows -> app.application", + "app.agent.tools.impl.query_workflows -> app.application.agentdata", "app.agent.tools.impl.query_workflows -> app.runtime", "app.agent.tools.impl.query_workflows -> app.runtime.log", "app.agent.tools.impl.read_file -> app.agent", @@ -1221,10 +1133,8 @@ "app.agent.tools.impl.run_agent_task -> app.agent.tools.base", "app.agent.tools.impl.run_agent_task -> app.agent.tools.tags", "app.agent.tools.impl.run_agent_task -> app.application", + "app.agent.tools.impl.run_agent_task -> app.application.agentdata", "app.agent.tools.impl.run_agent_task -> app.application.scheduling", - "app.agent.tools.impl.run_agent_task -> app.db", - "app.agent.tools.impl.run_agent_task -> app.db.oper", - "app.agent.tools.impl.run_agent_task -> app.db.oper.agenttask", "app.agent.tools.impl.run_scheduler -> app.agent", "app.agent.tools.impl.run_scheduler -> app.agent.tools", "app.agent.tools.impl.run_scheduler -> app.agent.tools.base", @@ -1248,11 +1158,10 @@ "app.agent.tools.impl.run_workflow -> app.agent.tools", "app.agent.tools.impl.run_workflow -> app.agent.tools.base", "app.agent.tools.impl.run_workflow -> app.agent.tools.tags", + "app.agent.tools.impl.run_workflow -> app.application", + "app.agent.tools.impl.run_workflow -> app.application.workflow", "app.agent.tools.impl.run_workflow -> app.chain", "app.agent.tools.impl.run_workflow -> app.chain.workflow", - "app.agent.tools.impl.run_workflow -> app.db", - "app.agent.tools.impl.run_workflow -> app.db.oper", - "app.agent.tools.impl.run_workflow -> app.db.oper.workflow", "app.agent.tools.impl.run_workflow -> app.runtime", "app.agent.tools.impl.run_workflow -> app.runtime.log", "app.agent.tools.impl.scrape_metadata -> app.agent", @@ -1312,11 +1221,10 @@ "app.agent.tools.impl.search_subscribe -> app.agent.tools", "app.agent.tools.impl.search_subscribe -> app.agent.tools.base", "app.agent.tools.impl.search_subscribe -> app.agent.tools.tags", + "app.agent.tools.impl.search_subscribe -> app.application", + "app.agent.tools.impl.search_subscribe -> app.application.agentdata", "app.agent.tools.impl.search_subscribe -> app.chain", "app.agent.tools.impl.search_subscribe -> app.chain.subscribe", - "app.agent.tools.impl.search_subscribe -> app.db", - "app.agent.tools.impl.search_subscribe -> app.db.oper", - "app.agent.tools.impl.search_subscribe -> app.db.oper.subscribe", "app.agent.tools.impl.search_subscribe -> app.runtime", "app.agent.tools.impl.search_subscribe -> app.runtime.log", "app.agent.tools.impl.search_subscribe -> app.schemas", @@ -1328,12 +1236,10 @@ "app.agent.tools.impl.search_torrents -> app.agent.tools.impl._torrent_search_utils", "app.agent.tools.impl.search_torrents -> app.agent.tools.tags", "app.agent.tools.impl.search_torrents -> app.application", + "app.agent.tools.impl.search_torrents -> app.application.configuration", "app.agent.tools.impl.search_torrents -> app.application.site", "app.agent.tools.impl.search_torrents -> app.chain", "app.agent.tools.impl.search_torrents -> app.chain.search", - "app.agent.tools.impl.search_torrents -> app.db", - "app.agent.tools.impl.search_torrents -> app.db.oper", - "app.agent.tools.impl.search_torrents -> app.db.oper.systemconfig", "app.agent.tools.impl.search_torrents -> app.domain", "app.agent.tools.impl.search_torrents -> app.domain.media", "app.agent.tools.impl.search_torrents -> app.runtime", @@ -1388,11 +1294,10 @@ "app.agent.tools.impl.test_site -> app.agent.tools", "app.agent.tools.impl.test_site -> app.agent.tools.base", "app.agent.tools.impl.test_site -> app.agent.tools.tags", + "app.agent.tools.impl.test_site -> app.application", + "app.agent.tools.impl.test_site -> app.application.agentdata", "app.agent.tools.impl.test_site -> app.chain", "app.agent.tools.impl.test_site -> app.chain.site", - "app.agent.tools.impl.test_site -> app.db", - "app.agent.tools.impl.test_site -> app.db.oper", - "app.agent.tools.impl.test_site -> app.db.oper.site", "app.agent.tools.impl.test_site -> app.runtime", "app.agent.tools.impl.test_site -> app.runtime.log", "app.agent.tools.impl.transfer_file -> app.agent", @@ -1421,10 +1326,8 @@ "app.agent.tools.impl.update_agent_task -> app.agent.tools.base", "app.agent.tools.impl.update_agent_task -> app.agent.tools.tags", "app.agent.tools.impl.update_agent_task -> app.application", + "app.agent.tools.impl.update_agent_task -> app.application.agentdata", "app.agent.tools.impl.update_agent_task -> app.application.scheduling", - "app.agent.tools.impl.update_agent_task -> app.db", - "app.agent.tools.impl.update_agent_task -> app.db.oper", - "app.agent.tools.impl.update_agent_task -> app.db.oper.agenttask", "app.agent.tools.impl.update_agent_task -> app.runtime", "app.agent.tools.impl.update_agent_task -> app.runtime.config", "app.agent.tools.impl.update_agent_task -> app.runtime.scheduling", @@ -1442,9 +1345,8 @@ "app.agent.tools.impl.update_custom_identifiers -> app.agent.tools", "app.agent.tools.impl.update_custom_identifiers -> app.agent.tools.base", "app.agent.tools.impl.update_custom_identifiers -> app.agent.tools.tags", - "app.agent.tools.impl.update_custom_identifiers -> app.db", - "app.agent.tools.impl.update_custom_identifiers -> app.db.oper", - "app.agent.tools.impl.update_custom_identifiers -> app.db.oper.systemconfig", + "app.agent.tools.impl.update_custom_identifiers -> app.application", + "app.agent.tools.impl.update_custom_identifiers -> app.application.configuration", "app.agent.tools.impl.update_custom_identifiers -> app.domain", "app.agent.tools.impl.update_custom_identifiers -> app.domain.metainfo", "app.agent.tools.impl.update_custom_identifiers -> app.runtime", @@ -1474,9 +1376,10 @@ "app.agent.tools.impl.update_plugin_config -> app.agent.tools.impl", "app.agent.tools.impl.update_plugin_config -> app.agent.tools.impl._plugin_tool_utils", "app.agent.tools.impl.update_plugin_config -> app.agent.tools.tags", + "app.agent.tools.impl.update_plugin_config -> app.application", + "app.agent.tools.impl.update_plugin_config -> app.application.plugin", + "app.agent.tools.impl.update_plugin_config -> app.application.plugin.runtime", "app.agent.tools.impl.update_plugin_config -> app.runtime", - "app.agent.tools.impl.update_plugin_config -> app.runtime.extensions", - "app.agent.tools.impl.update_plugin_config -> app.runtime.extensions.plugin_manager", "app.agent.tools.impl.update_plugin_config -> app.runtime.log", "app.agent.tools.impl.update_rule_group -> app.agent", "app.agent.tools.impl.update_rule_group -> app.agent.tools", @@ -1492,9 +1395,8 @@ "app.agent.tools.impl.update_site -> app.agent.tools", "app.agent.tools.impl.update_site -> app.agent.tools.base", "app.agent.tools.impl.update_site -> app.agent.tools.tags", - "app.agent.tools.impl.update_site -> app.db", - "app.agent.tools.impl.update_site -> app.db.oper", - "app.agent.tools.impl.update_site -> app.db.oper.site", + "app.agent.tools.impl.update_site -> app.application", + "app.agent.tools.impl.update_site -> app.application.agentdata", "app.agent.tools.impl.update_site -> app.foundation", "app.agent.tools.impl.update_site -> app.foundation.url", "app.agent.tools.impl.update_site -> app.runtime", @@ -1506,20 +1408,18 @@ "app.agent.tools.impl.update_site_cookie -> app.agent.tools", "app.agent.tools.impl.update_site_cookie -> app.agent.tools.base", "app.agent.tools.impl.update_site_cookie -> app.agent.tools.tags", + "app.agent.tools.impl.update_site_cookie -> app.application", + "app.agent.tools.impl.update_site_cookie -> app.application.agentdata", "app.agent.tools.impl.update_site_cookie -> app.chain", "app.agent.tools.impl.update_site_cookie -> app.chain.site", - "app.agent.tools.impl.update_site_cookie -> app.db", - "app.agent.tools.impl.update_site_cookie -> app.db.oper", - "app.agent.tools.impl.update_site_cookie -> app.db.oper.site", "app.agent.tools.impl.update_site_cookie -> app.runtime", "app.agent.tools.impl.update_site_cookie -> app.runtime.log", "app.agent.tools.impl.update_subscribe -> app.agent", "app.agent.tools.impl.update_subscribe -> app.agent.tools", "app.agent.tools.impl.update_subscribe -> app.agent.tools.base", "app.agent.tools.impl.update_subscribe -> app.agent.tools.tags", - "app.agent.tools.impl.update_subscribe -> app.db", - "app.agent.tools.impl.update_subscribe -> app.db.oper", - "app.agent.tools.impl.update_subscribe -> app.db.oper.subscribe", + "app.agent.tools.impl.update_subscribe -> app.application", + "app.agent.tools.impl.update_subscribe -> app.application.agentdata", "app.agent.tools.impl.update_subscribe -> app.runtime", "app.agent.tools.impl.update_subscribe -> app.runtime.events", "app.agent.tools.impl.update_subscribe -> app.runtime.log", @@ -1532,9 +1432,8 @@ "app.agent.tools.impl.update_system_settings -> app.agent.tools.impl", "app.agent.tools.impl.update_system_settings -> app.agent.tools.impl._system_setting_utils", "app.agent.tools.impl.update_system_settings -> app.agent.tools.tags", - "app.agent.tools.impl.update_system_settings -> app.db", - "app.agent.tools.impl.update_system_settings -> app.db.oper", - "app.agent.tools.impl.update_system_settings -> app.db.oper.systemconfig", + "app.agent.tools.impl.update_system_settings -> app.application", + "app.agent.tools.impl.update_system_settings -> app.application.configuration", "app.agent.tools.impl.update_system_settings -> app.runtime", "app.agent.tools.impl.update_system_settings -> app.runtime.config", "app.agent.tools.impl.update_system_settings -> app.runtime.events", @@ -1556,44 +1455,52 @@ "app.agent.tools.manager -> app.agent.tools", "app.agent.tools.manager -> app.agent.tools.base", "app.agent.tools.manager -> app.agent.tools.catalog", + "app.agent.tools.manager -> app.application", + "app.agent.tools.manager -> app.application.plugin", + "app.agent.tools.manager -> app.application.plugin.runtime", "app.agent.tools.manager -> app.runtime", - "app.agent.tools.manager -> app.runtime.extensions", - "app.agent.tools.manager -> app.runtime.extensions.plugin_manager", "app.agent.tools.manager -> app.runtime.log", "app.api.apiv1 -> app.api", "app.api.apiv1 -> app.api.router_specs", "app.api.deps -> app.adapters", "app.api.deps -> app.adapters.external", "app.api.deps -> app.adapters.external.server", + "app.api.deps -> app.adapters.web", + "app.api.deps -> app.adapters.web.security", + "app.api.deps -> app.adapters.web.security.access", + "app.api.deps -> app.api", + "app.api.deps -> app.api.data", "app.api.deps -> app.application", "app.api.deps -> app.application.commands", + "app.api.deps -> app.application.dashboard", "app.api.deps -> app.application.history", + "app.api.deps -> app.application.mediaserver", + "app.api.deps -> app.application.messaging", + "app.api.deps -> app.application.messaging.chat", + "app.api.deps -> app.application.messaging.message", "app.api.deps -> app.application.plugin", "app.api.deps -> app.application.plugin.config", + "app.api.deps -> app.application.plugin.runtime", "app.api.deps -> app.application.plugins", "app.api.deps -> app.application.scheduling", "app.api.deps -> app.application.security", - "app.api.deps -> app.application.security.access", + "app.api.deps -> app.application.security.auth", + "app.api.deps -> app.application.security.passkeys", + "app.api.deps -> app.application.security.user", + "app.api.deps -> app.application.servarr", "app.api.deps -> app.application.site", "app.api.deps -> app.application.site.mutation", + "app.api.deps -> app.application.site.query", "app.api.deps -> app.application.subscription", "app.api.deps -> app.application.subscription.delete", "app.api.deps -> app.application.subscription.identity", + "app.api.deps -> app.application.subscription.mutation", + "app.api.deps -> app.application.subscription.query", "app.api.deps -> app.application.subscription.search", "app.api.deps -> app.application.workflow", "app.api.deps -> app.chain", + "app.api.deps -> app.chain.dashboard", "app.api.deps -> app.chain.storage", - "app.api.deps -> app.db", - "app.api.deps -> app.db.models", - "app.api.deps -> app.db.models.user", - "app.api.deps -> app.db.oper", - "app.api.deps -> app.db.oper.downloadhistory", - "app.api.deps -> app.db.oper.site", - "app.api.deps -> app.db.oper.subscribe", - "app.api.deps -> app.db.oper.systemconfig", - "app.api.deps -> app.db.oper.transferhistory", - "app.api.deps -> app.db.oper.workflow", - "app.api.deps -> app.db.uow", "app.api.deps -> app.domain", "app.api.deps -> app.domain.site", "app.api.deps -> app.foundation", @@ -1601,10 +1508,7 @@ "app.api.deps -> app.runtime", "app.api.deps -> app.runtime.config", "app.api.deps -> app.runtime.events", - "app.api.deps -> app.runtime.extensions", - "app.api.deps -> app.runtime.extensions.plugin_manager", "app.api.deps -> app.runtime.log", - "app.api.deps -> app.scheduler", "app.api.deps -> app.schemas", "app.api.deps -> app.schemas.event", "app.api.deps -> app.schemas.token", @@ -1620,20 +1524,18 @@ "app.api.endpoints.agent -> app.agent.runtime_loader", "app.api.endpoints.agent -> app.api", "app.api.endpoints.agent -> app.api.deps", + "app.api.endpoints.agent -> app.api.principal", "app.api.endpoints.agent -> app.api.response", "app.api.endpoints.agent -> app.application", "app.api.endpoints.agent -> app.application.messaging", "app.api.endpoints.agent -> app.application.messaging.agent", + "app.api.endpoints.agent -> app.application.messaging.chat", "app.api.endpoints.agent -> app.application.messaging.router", + "app.api.endpoints.agent -> app.application.security", + "app.api.endpoints.agent -> app.application.security.user", "app.api.endpoints.agent -> app.chain", "app.api.endpoints.agent -> app.chain.message", "app.api.endpoints.agent -> app.command", - "app.api.endpoints.agent -> app.db", - "app.api.endpoints.agent -> app.db.models", - "app.api.endpoints.agent -> app.db.models.agentchat", - "app.api.endpoints.agent -> app.db.oper", - "app.api.endpoints.agent -> app.db.oper.agentchat", - "app.api.endpoints.agent -> app.db.oper.user", "app.api.endpoints.agent -> app.runtime", "app.api.endpoints.agent -> app.runtime.config", "app.api.endpoints.agent -> app.runtime.events", @@ -1644,11 +1546,12 @@ "app.api.endpoints.agent -> app.schemas.message", "app.api.endpoints.agent -> app.schemas.response", "app.api.endpoints.agent -> app.schemas.types", + "app.api.endpoints.anilist -> app.adapters", + "app.api.endpoints.anilist -> app.adapters.web", + "app.api.endpoints.anilist -> app.adapters.web.security", + "app.api.endpoints.anilist -> app.adapters.web.security.access", "app.api.endpoints.anilist -> app.api", "app.api.endpoints.anilist -> app.api.response", - "app.api.endpoints.anilist -> app.application", - "app.api.endpoints.anilist -> app.application.security", - "app.api.endpoints.anilist -> app.application.security.access", "app.api.endpoints.anilist -> app.chain", "app.api.endpoints.anilist -> app.chain.anilist", "app.api.endpoints.anilist -> app.domain", @@ -1657,39 +1560,37 @@ "app.api.endpoints.anilist -> app.schemas.context", "app.api.endpoints.anilist -> app.schemas.token", "app.api.endpoints.anilist -> app.schemas.workflow", + "app.api.endpoints.anthropic -> app.adapters", + "app.api.endpoints.anthropic -> app.adapters.web", + "app.api.endpoints.anthropic -> app.adapters.web.security", + "app.api.endpoints.anthropic -> app.adapters.web.security.access", "app.api.endpoints.anthropic -> app.agent", "app.api.endpoints.anthropic -> app.agent.runtime_loader", "app.api.endpoints.anthropic -> app.api", "app.api.endpoints.anthropic -> app.api.endpoints", "app.api.endpoints.anthropic -> app.api.endpoints.openai", "app.api.endpoints.anthropic -> app.api.openai_utils", - "app.api.endpoints.anthropic -> app.application", - "app.api.endpoints.anthropic -> app.application.security", - "app.api.endpoints.anthropic -> app.application.security.access", "app.api.endpoints.anthropic -> app.runtime", "app.api.endpoints.anthropic -> app.runtime.config", "app.api.endpoints.anthropic -> app.schemas", "app.api.endpoints.anthropic -> app.schemas.openai", "app.api.endpoints.auth -> app.api", + "app.api.endpoints.auth -> app.api.deps", "app.api.endpoints.auth -> app.api.response", "app.api.endpoints.auth -> app.application", + "app.api.endpoints.auth -> app.application.plugin", + "app.api.endpoints.auth -> app.application.plugin.runtime", "app.api.endpoints.auth -> app.application.security", "app.api.endpoints.auth -> app.application.security.auth", - "app.api.endpoints.auth -> app.db", - "app.api.endpoints.auth -> app.db.models", - "app.api.endpoints.auth -> app.db.models.passkey", - "app.api.endpoints.auth -> app.db.models.user", - "app.api.endpoints.auth -> app.runtime", - "app.api.endpoints.auth -> app.runtime.extensions", - "app.api.endpoints.auth -> app.runtime.extensions.plugin_manager", "app.api.endpoints.auth -> app.schemas", "app.api.endpoints.auth -> app.schemas.token", "app.api.endpoints.auth -> app.schemas.user", + "app.api.endpoints.bangumi -> app.adapters", + "app.api.endpoints.bangumi -> app.adapters.web", + "app.api.endpoints.bangumi -> app.adapters.web.security", + "app.api.endpoints.bangumi -> app.adapters.web.security.access", "app.api.endpoints.bangumi -> app.api", "app.api.endpoints.bangumi -> app.api.response", - "app.api.endpoints.bangumi -> app.application", - "app.api.endpoints.bangumi -> app.application.security", - "app.api.endpoints.bangumi -> app.application.security.access", "app.api.endpoints.bangumi -> app.chain", "app.api.endpoints.bangumi -> app.chain.bangumi", "app.api.endpoints.bangumi -> app.domain", @@ -1701,31 +1602,31 @@ "app.api.endpoints.dashboard -> app.adapters", "app.api.endpoints.dashboard -> app.adapters.system", "app.api.endpoints.dashboard -> app.adapters.system.host", + "app.api.endpoints.dashboard -> app.adapters.web", + "app.api.endpoints.dashboard -> app.adapters.web.security", + "app.api.endpoints.dashboard -> app.adapters.web.security.access", "app.api.endpoints.dashboard -> app.api", "app.api.endpoints.dashboard -> app.api.deps", "app.api.endpoints.dashboard -> app.api.response", "app.api.endpoints.dashboard -> app.application", + "app.api.endpoints.dashboard -> app.application.dashboard", "app.api.endpoints.dashboard -> app.application.directory", - "app.api.endpoints.dashboard -> app.application.security", - "app.api.endpoints.dashboard -> app.application.security.access", + "app.api.endpoints.dashboard -> app.application.scheduling", "app.api.endpoints.dashboard -> app.chain", "app.api.endpoints.dashboard -> app.chain.dashboard", "app.api.endpoints.dashboard -> app.chain.storage", - "app.api.endpoints.dashboard -> app.db", - "app.api.endpoints.dashboard -> app.db.models", - "app.api.endpoints.dashboard -> app.db.models.transferhistory", "app.api.endpoints.dashboard -> app.runtime", "app.api.endpoints.dashboard -> app.runtime.config", - "app.api.endpoints.dashboard -> app.scheduler", "app.api.endpoints.dashboard -> app.schemas", "app.api.endpoints.dashboard -> app.schemas.dashboard", "app.api.endpoints.dashboard -> app.schemas.response", "app.api.endpoints.dashboard -> app.schemas.types", + "app.api.endpoints.discover -> app.adapters", + "app.api.endpoints.discover -> app.adapters.web", + "app.api.endpoints.discover -> app.adapters.web.security", + "app.api.endpoints.discover -> app.adapters.web.security.access", "app.api.endpoints.discover -> app.api", "app.api.endpoints.discover -> app.api.response", - "app.api.endpoints.discover -> app.application", - "app.api.endpoints.discover -> app.application.security", - "app.api.endpoints.discover -> app.application.security.access", "app.api.endpoints.discover -> app.chain", "app.api.endpoints.discover -> app.chain.bangumi", "app.api.endpoints.discover -> app.chain.douban", @@ -1737,11 +1638,12 @@ "app.api.endpoints.discover -> app.schemas.token", "app.api.endpoints.discover -> app.schemas.types", "app.api.endpoints.discover -> app.schemas.workflow", + "app.api.endpoints.douban -> app.adapters", + "app.api.endpoints.douban -> app.adapters.web", + "app.api.endpoints.douban -> app.adapters.web.security", + "app.api.endpoints.douban -> app.adapters.web.security.access", "app.api.endpoints.douban -> app.api", "app.api.endpoints.douban -> app.api.response", - "app.api.endpoints.douban -> app.application", - "app.api.endpoints.douban -> app.application.security", - "app.api.endpoints.douban -> app.application.security.access", "app.api.endpoints.douban -> app.chain", "app.api.endpoints.douban -> app.chain.douban", "app.api.endpoints.douban -> app.domain", @@ -1751,23 +1653,24 @@ "app.api.endpoints.douban -> app.schemas.token", "app.api.endpoints.douban -> app.schemas.types", "app.api.endpoints.douban -> app.schemas.workflow", + "app.api.endpoints.download -> app.adapters", + "app.api.endpoints.download -> app.adapters.web", + "app.api.endpoints.download -> app.adapters.web.security", + "app.api.endpoints.download -> app.adapters.web.security.access", "app.api.endpoints.download -> app.api", "app.api.endpoints.download -> app.api.deps", + "app.api.endpoints.download -> app.api.principal", "app.api.endpoints.download -> app.api.response", "app.api.endpoints.download -> app.application", + "app.api.endpoints.download -> app.application.configuration", "app.api.endpoints.download -> app.application.directory", "app.api.endpoints.download -> app.application.security", - "app.api.endpoints.download -> app.application.security.access", "app.api.endpoints.download -> app.application.security.url", + "app.api.endpoints.download -> app.application.site", + "app.api.endpoints.download -> app.application.site.query", "app.api.endpoints.download -> app.chain", "app.api.endpoints.download -> app.chain.download", "app.api.endpoints.download -> app.chain.media", - "app.api.endpoints.download -> app.db", - "app.api.endpoints.download -> app.db.models", - "app.api.endpoints.download -> app.db.models.user", - "app.api.endpoints.download -> app.db.oper", - "app.api.endpoints.download -> app.db.oper.site", - "app.api.endpoints.download -> app.db.oper.systemconfig", "app.api.endpoints.download -> app.domain", "app.api.endpoints.download -> app.domain.context", "app.api.endpoints.download -> app.domain.media", @@ -1785,6 +1688,10 @@ "app.api.endpoints.download -> app.schemas.transfer", "app.api.endpoints.download -> app.schemas.types", "app.api.endpoints.download -> app.schemas.workflow", + "app.api.endpoints.history -> app.adapters", + "app.api.endpoints.history -> app.adapters.web", + "app.api.endpoints.history -> app.adapters.web.security", + "app.api.endpoints.history -> app.adapters.web.security.access", "app.api.endpoints.history -> app.agent", "app.api.endpoints.history -> app.agent.contracts", "app.api.endpoints.history -> app.agent.prompt", @@ -1795,14 +1702,6 @@ "app.api.endpoints.history -> app.api.response", "app.api.endpoints.history -> app.application", "app.api.endpoints.history -> app.application.history", - "app.api.endpoints.history -> app.application.security", - "app.api.endpoints.history -> app.application.security.access", - "app.api.endpoints.history -> app.db", - "app.api.endpoints.history -> app.db.models", - "app.api.endpoints.history -> app.db.models.downloadhistory", - "app.api.endpoints.history -> app.db.models.transferhistory", - "app.api.endpoints.history -> app.foundation", - "app.api.endpoints.history -> app.foundation.text", "app.api.endpoints.history -> app.runtime", "app.api.endpoints.history -> app.runtime.config", "app.api.endpoints.history -> app.runtime.log", @@ -1818,54 +1717,54 @@ "app.api.endpoints.llm -> app.api", "app.api.endpoints.llm -> app.api.deps", "app.api.endpoints.llm -> app.api.response", - "app.api.endpoints.llm -> app.db", - "app.api.endpoints.llm -> app.db.models", "app.api.endpoints.llm -> app.schemas", "app.api.endpoints.llm -> app.schemas.common", "app.api.endpoints.llm -> app.schemas.response", + "app.api.endpoints.login -> app.adapters", + "app.api.endpoints.login -> app.adapters.web", + "app.api.endpoints.login -> app.adapters.web.security", + "app.api.endpoints.login -> app.adapters.web.security.access", "app.api.endpoints.login -> app.api", "app.api.endpoints.login -> app.api.response", "app.api.endpoints.login -> app.application", + "app.api.endpoints.login -> app.application.configuration", "app.api.endpoints.login -> app.application.image", "app.api.endpoints.login -> app.application.security", - "app.api.endpoints.login -> app.application.security.access", + "app.api.endpoints.login -> app.application.security.token", "app.api.endpoints.login -> app.application.site", "app.api.endpoints.login -> app.chain", "app.api.endpoints.login -> app.chain.user", - "app.api.endpoints.login -> app.db", - "app.api.endpoints.login -> app.db.oper", - "app.api.endpoints.login -> app.db.oper.systemconfig", "app.api.endpoints.login -> app.runtime", "app.api.endpoints.login -> app.runtime.config", "app.api.endpoints.login -> app.schemas", "app.api.endpoints.login -> app.schemas.response", "app.api.endpoints.login -> app.schemas.token", "app.api.endpoints.login -> app.schemas.types", + "app.api.endpoints.mcp -> app.adapters", + "app.api.endpoints.mcp -> app.adapters.web", + "app.api.endpoints.mcp -> app.adapters.web.security", + "app.api.endpoints.mcp -> app.adapters.web.security.access", "app.api.endpoints.mcp -> app.agent", "app.api.endpoints.mcp -> app.agent.tools", "app.api.endpoints.mcp -> app.agent.tools.manager", "app.api.endpoints.mcp -> app.api", "app.api.endpoints.mcp -> app.api.response", - "app.api.endpoints.mcp -> app.application", - "app.api.endpoints.mcp -> app.application.security", - "app.api.endpoints.mcp -> app.application.security.access", "app.api.endpoints.mcp -> app.runtime", "app.api.endpoints.mcp -> app.runtime.log", "app.api.endpoints.mcp -> app.schemas", "app.api.endpoints.mcp -> app.schemas.mcp", "app.api.endpoints.mcp -> app.schemas.response", + "app.api.endpoints.media -> app.adapters", + "app.api.endpoints.media -> app.adapters.web", + "app.api.endpoints.media -> app.adapters.web.security", + "app.api.endpoints.media -> app.adapters.web.security.access", "app.api.endpoints.media -> app.api", "app.api.endpoints.media -> app.api.deps", "app.api.endpoints.media -> app.api.response", - "app.api.endpoints.media -> app.application", - "app.api.endpoints.media -> app.application.security", - "app.api.endpoints.media -> app.application.security.access", "app.api.endpoints.media -> app.chain", "app.api.endpoints.media -> app.chain.media", "app.api.endpoints.media -> app.chain.scraping", "app.api.endpoints.media -> app.chain.tmdb", - "app.api.endpoints.media -> app.db", - "app.api.endpoints.media -> app.db.models", "app.api.endpoints.media -> app.domain", "app.api.endpoints.media -> app.domain.context", "app.api.endpoints.media -> app.domain.media", @@ -1883,20 +1782,19 @@ "app.api.endpoints.media -> app.schemas.token", "app.api.endpoints.media -> app.schemas.types", "app.api.endpoints.media -> app.schemas.workflow", + "app.api.endpoints.mediaserver -> app.adapters", + "app.api.endpoints.mediaserver -> app.adapters.web", + "app.api.endpoints.mediaserver -> app.adapters.web.security", + "app.api.endpoints.mediaserver -> app.adapters.web.security.access", "app.api.endpoints.mediaserver -> app.api", + "app.api.endpoints.mediaserver -> app.api.deps", "app.api.endpoints.mediaserver -> app.api.response", "app.api.endpoints.mediaserver -> app.application", + "app.api.endpoints.mediaserver -> app.application.configuration", "app.api.endpoints.mediaserver -> app.application.mediaserver", - "app.api.endpoints.mediaserver -> app.application.security", - "app.api.endpoints.mediaserver -> app.application.security.access", "app.api.endpoints.mediaserver -> app.chain", "app.api.endpoints.mediaserver -> app.chain.download", "app.api.endpoints.mediaserver -> app.chain.mediaserver", - "app.api.endpoints.mediaserver -> app.db", - "app.api.endpoints.mediaserver -> app.db.models", - "app.api.endpoints.mediaserver -> app.db.oper", - "app.api.endpoints.mediaserver -> app.db.oper.mediaserver", - "app.api.endpoints.mediaserver -> app.db.oper.systemconfig", "app.api.endpoints.mediaserver -> app.domain", "app.api.endpoints.mediaserver -> app.domain.context", "app.api.endpoints.mediaserver -> app.domain.metainfo", @@ -1911,67 +1809,64 @@ "app.api.endpoints.message -> app.adapters", "app.api.endpoints.message -> app.adapters.external", "app.api.endpoints.message -> app.adapters.external.wechat_crypt", + "app.api.endpoints.message -> app.adapters.web", + "app.api.endpoints.message -> app.adapters.web.security", + "app.api.endpoints.message -> app.adapters.web.security.access", "app.api.endpoints.message -> app.api", "app.api.endpoints.message -> app.api.deps", + "app.api.endpoints.message -> app.api.principal", "app.api.endpoints.message -> app.api.response", "app.api.endpoints.message -> app.application", - "app.api.endpoints.message -> app.application.security", - "app.api.endpoints.message -> app.application.security.access", + "app.api.endpoints.message -> app.application.configuration", + "app.api.endpoints.message -> app.application.messaging", + "app.api.endpoints.message -> app.application.messaging.message", "app.api.endpoints.message -> app.chain", "app.api.endpoints.message -> app.chain.message", - "app.api.endpoints.message -> app.db", - "app.api.endpoints.message -> app.db.models", - "app.api.endpoints.message -> app.db.oper", - "app.api.endpoints.message -> app.db.oper.message", - "app.api.endpoints.message -> app.db.oper.systemconfig", "app.api.endpoints.message -> app.runtime", "app.api.endpoints.message -> app.runtime.config", "app.api.endpoints.message -> app.runtime.extensions", - "app.api.endpoints.message -> app.runtime.extensions.service_registry", + "app.api.endpoints.message -> app.runtime.extensions.service_config", "app.api.endpoints.message -> app.runtime.log", "app.api.endpoints.message -> app.schemas", "app.api.endpoints.message -> app.schemas.message", "app.api.endpoints.message -> app.schemas.response", "app.api.endpoints.message -> app.schemas.token", "app.api.endpoints.message -> app.schemas.types", + "app.api.endpoints.mfa -> app.adapters", + "app.api.endpoints.mfa -> app.adapters.web", + "app.api.endpoints.mfa -> app.adapters.web.security", + "app.api.endpoints.mfa -> app.adapters.web.security.access", "app.api.endpoints.mfa -> app.api", "app.api.endpoints.mfa -> app.api.deps", + "app.api.endpoints.mfa -> app.api.principal", "app.api.endpoints.mfa -> app.api.response", "app.api.endpoints.mfa -> app.application", "app.api.endpoints.mfa -> app.application.security", - "app.api.endpoints.mfa -> app.application.security.access", + "app.api.endpoints.mfa -> app.application.security.auth", "app.api.endpoints.mfa -> app.application.security.otp", "app.api.endpoints.mfa -> app.application.security.passkey", - "app.api.endpoints.mfa -> app.application.site", - "app.api.endpoints.mfa -> app.db", - "app.api.endpoints.mfa -> app.db.models", - "app.api.endpoints.mfa -> app.db.models.passkey", - "app.api.endpoints.mfa -> app.db.models.user", - "app.api.endpoints.mfa -> app.db.oper", - "app.api.endpoints.mfa -> app.db.oper.systemconfig", + "app.api.endpoints.mfa -> app.application.security.passkeys", + "app.api.endpoints.mfa -> app.application.security.token", + "app.api.endpoints.mfa -> app.application.security.user", "app.api.endpoints.mfa -> app.runtime", - "app.api.endpoints.mfa -> app.runtime.config", "app.api.endpoints.mfa -> app.runtime.log", "app.api.endpoints.mfa -> app.schemas", "app.api.endpoints.mfa -> app.schemas.mcp", "app.api.endpoints.mfa -> app.schemas.mfa", "app.api.endpoints.mfa -> app.schemas.response", "app.api.endpoints.mfa -> app.schemas.token", - "app.api.endpoints.mfa -> app.schemas.types", + "app.api.endpoints.music -> app.adapters", + "app.api.endpoints.music -> app.adapters.web", + "app.api.endpoints.music -> app.adapters.web.security", + "app.api.endpoints.music -> app.adapters.web.security.access", "app.api.endpoints.music -> app.api", "app.api.endpoints.music -> app.api.deps", "app.api.endpoints.music -> app.api.response", - "app.api.endpoints.music -> app.application", - "app.api.endpoints.music -> app.application.security", - "app.api.endpoints.music -> app.application.security.access", "app.api.endpoints.music -> app.chain", "app.api.endpoints.music -> app.chain.listenbrainz", "app.api.endpoints.music -> app.chain.media", "app.api.endpoints.music -> app.chain.musicbrainz", "app.api.endpoints.music -> app.chain.recommend", - "app.api.endpoints.music -> app.db", - "app.api.endpoints.music -> app.db.models", - "app.api.endpoints.music -> app.db.models.user", "app.api.endpoints.music -> app.domain", "app.api.endpoints.music -> app.domain.context", "app.api.endpoints.music -> app.schemas", @@ -1985,20 +1880,19 @@ "app.api.endpoints.notification -> app.api.response", "app.api.endpoints.notification -> app.chain", "app.api.endpoints.notification -> app.chain.notification", - "app.api.endpoints.notification -> app.db", - "app.api.endpoints.notification -> app.db.models", "app.api.endpoints.notification -> app.schemas", "app.api.endpoints.notification -> app.schemas.common", "app.api.endpoints.notification -> app.schemas.response", + "app.api.endpoints.openai -> app.adapters", + "app.api.endpoints.openai -> app.adapters.web", + "app.api.endpoints.openai -> app.adapters.web.security", + "app.api.endpoints.openai -> app.adapters.web.security.access", "app.api.endpoints.openai -> app.agent", "app.api.endpoints.openai -> app.agent.callback", "app.api.endpoints.openai -> app.agent.contracts", "app.api.endpoints.openai -> app.agent.runtime_loader", "app.api.endpoints.openai -> app.api", "app.api.endpoints.openai -> app.api.openai_utils", - "app.api.endpoints.openai -> app.application", - "app.api.endpoints.openai -> app.application.security", - "app.api.endpoints.openai -> app.application.security.access", "app.api.endpoints.openai -> app.runtime", "app.api.endpoints.openai -> app.runtime.config", "app.api.endpoints.openai -> app.schemas", @@ -2011,27 +1905,28 @@ "app.api.endpoints.plugin -> app.adapters.system", "app.api.endpoints.plugin -> app.adapters.system.plugin", "app.api.endpoints.plugin -> app.adapters.system.plugin.package", + "app.api.endpoints.plugin -> app.adapters.web", + "app.api.endpoints.plugin -> app.adapters.web.security", + "app.api.endpoints.plugin -> app.adapters.web.security.access", "app.api.endpoints.plugin -> app.api", "app.api.endpoints.plugin -> app.api.deps", + "app.api.endpoints.plugin -> app.api.principal", "app.api.endpoints.plugin -> app.api.response", "app.api.endpoints.plugin -> app.application", "app.api.endpoints.plugin -> app.application.commands", + "app.api.endpoints.plugin -> app.application.configuration", "app.api.endpoints.plugin -> app.application.plugin", "app.api.endpoints.plugin -> app.application.plugin.config", "app.api.endpoints.plugin -> app.application.plugin.install", + "app.api.endpoints.plugin -> app.application.plugin.runtime", "app.api.endpoints.plugin -> app.application.plugins", "app.api.endpoints.plugin -> app.application.scheduling", - "app.api.endpoints.plugin -> app.application.security", - "app.api.endpoints.plugin -> app.application.security.access", - "app.api.endpoints.plugin -> app.db", - "app.api.endpoints.plugin -> app.db.models", - "app.api.endpoints.plugin -> app.db.oper", - "app.api.endpoints.plugin -> app.db.oper.systemconfig", "app.api.endpoints.plugin -> app.runtime", "app.api.endpoints.plugin -> app.runtime.cache", "app.api.endpoints.plugin -> app.runtime.config", "app.api.endpoints.plugin -> app.runtime.extensions", - "app.api.endpoints.plugin -> app.runtime.extensions.plugin_manager", + "app.api.endpoints.plugin -> app.runtime.extensions.plugin", + "app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts", "app.api.endpoints.plugin -> app.runtime.log", "app.api.endpoints.plugin -> app.schemas", "app.api.endpoints.plugin -> app.schemas.common", @@ -2039,11 +1934,12 @@ "app.api.endpoints.plugin -> app.schemas.response", "app.api.endpoints.plugin -> app.schemas.token", "app.api.endpoints.plugin -> app.schemas.types", + "app.api.endpoints.recommend -> app.adapters", + "app.api.endpoints.recommend -> app.adapters.web", + "app.api.endpoints.recommend -> app.adapters.web.security", + "app.api.endpoints.recommend -> app.adapters.web.security.access", "app.api.endpoints.recommend -> app.api", "app.api.endpoints.recommend -> app.api.response", - "app.api.endpoints.recommend -> app.application", - "app.api.endpoints.recommend -> app.application.security", - "app.api.endpoints.recommend -> app.application.security.access", "app.api.endpoints.recommend -> app.chain", "app.api.endpoints.recommend -> app.chain.recommend", "app.api.endpoints.recommend -> app.runtime", @@ -2055,11 +1951,14 @@ "app.api.endpoints.recommend -> app.schemas.transfer", "app.api.endpoints.recommend -> app.schemas.types", "app.api.endpoints.recommend -> app.schemas.workflow", + "app.api.endpoints.search -> app.adapters", + "app.api.endpoints.search -> app.adapters.web", + "app.api.endpoints.search -> app.adapters.web.security", + "app.api.endpoints.search -> app.adapters.web.security.access", "app.api.endpoints.search -> app.api", "app.api.endpoints.search -> app.api.response", "app.api.endpoints.search -> app.application", "app.api.endpoints.search -> app.application.security", - "app.api.endpoints.search -> app.application.security.access", "app.api.endpoints.search -> app.application.security.url", "app.api.endpoints.search -> app.chain", "app.api.endpoints.search -> app.chain.search", @@ -2076,37 +1975,32 @@ "app.api.endpoints.search -> app.schemas.token", "app.api.endpoints.search -> app.schemas.types", "app.api.endpoints.search -> app.schemas.workflow", + "app.api.endpoints.site -> app.adapters", + "app.api.endpoints.site -> app.adapters.web", + "app.api.endpoints.site -> app.adapters.web.security", + "app.api.endpoints.site -> app.adapters.web.security.access", "app.api.endpoints.site -> app.api", "app.api.endpoints.site -> app.api.deps", "app.api.endpoints.site -> app.api.endpoints", "app.api.endpoints.site -> app.api.endpoints.plugin", + "app.api.endpoints.site -> app.api.principal", "app.api.endpoints.site -> app.api.response", "app.api.endpoints.site -> app.application", - "app.api.endpoints.site -> app.application.security", - "app.api.endpoints.site -> app.application.security.access", + "app.api.endpoints.site -> app.application.configuration", + "app.api.endpoints.site -> app.application.plugin", + "app.api.endpoints.site -> app.application.plugin.runtime", + "app.api.endpoints.site -> app.application.scheduling", "app.api.endpoints.site -> app.application.site", "app.api.endpoints.site -> app.application.site.mutation", + "app.api.endpoints.site -> app.application.site.query", "app.api.endpoints.site -> app.chain", "app.api.endpoints.site -> app.chain.site", "app.api.endpoints.site -> app.chain.torrents", "app.api.endpoints.site -> app.command", - "app.api.endpoints.site -> app.db", - "app.api.endpoints.site -> app.db.models", - "app.api.endpoints.site -> app.db.models.site", - "app.api.endpoints.site -> app.db.models.siteicon", - "app.api.endpoints.site -> app.db.models.sitestatistic", - "app.api.endpoints.site -> app.db.models.siteuserdata", - "app.api.endpoints.site -> app.db.oper", - "app.api.endpoints.site -> app.db.oper.site", - "app.api.endpoints.site -> app.db.oper.systemconfig", "app.api.endpoints.site -> app.domain", "app.api.endpoints.site -> app.domain.site", "app.api.endpoints.site -> app.runtime", - "app.api.endpoints.site -> app.runtime.events", - "app.api.endpoints.site -> app.runtime.extensions", - "app.api.endpoints.site -> app.runtime.extensions.plugin_manager", "app.api.endpoints.site -> app.runtime.log", - "app.api.endpoints.site -> app.scheduler", "app.api.endpoints.site -> app.schemas", "app.api.endpoints.site -> app.schemas.common", "app.api.endpoints.site -> app.schemas.response", @@ -2117,13 +2011,12 @@ "app.api.endpoints.site -> app.schemas.workflow", "app.api.endpoints.storage -> app.api", "app.api.endpoints.storage -> app.api.deps", + "app.api.endpoints.storage -> app.api.principal", "app.api.endpoints.storage -> app.api.response", "app.api.endpoints.storage -> app.chain", "app.api.endpoints.storage -> app.chain.media", "app.api.endpoints.storage -> app.chain.storage", "app.api.endpoints.storage -> app.chain.transfer", - "app.api.endpoints.storage -> app.db", - "app.api.endpoints.storage -> app.db.models", "app.api.endpoints.storage -> app.foundation", "app.api.endpoints.storage -> app.foundation.text", "app.api.endpoints.storage -> app.runtime", @@ -2137,32 +2030,30 @@ "app.api.endpoints.subscribe -> app.adapters", "app.api.endpoints.subscribe -> app.adapters.external", "app.api.endpoints.subscribe -> app.adapters.external.server", + "app.api.endpoints.subscribe -> app.adapters.web", + "app.api.endpoints.subscribe -> app.adapters.web.security", + "app.api.endpoints.subscribe -> app.adapters.web.security.access", "app.api.endpoints.subscribe -> app.api", "app.api.endpoints.subscribe -> app.api.deps", + "app.api.endpoints.subscribe -> app.api.principal", "app.api.endpoints.subscribe -> app.api.response", "app.api.endpoints.subscribe -> app.application", - "app.api.endpoints.subscribe -> app.application.security", - "app.api.endpoints.subscribe -> app.application.security.access", + "app.api.endpoints.subscribe -> app.application.configuration", + "app.api.endpoints.subscribe -> app.application.scheduling", "app.api.endpoints.subscribe -> app.application.subscription", "app.api.endpoints.subscribe -> app.application.subscription.delete", "app.api.endpoints.subscribe -> app.application.subscription.identity", + "app.api.endpoints.subscribe -> app.application.subscription.mutation", + "app.api.endpoints.subscribe -> app.application.subscription.query", "app.api.endpoints.subscribe -> app.application.subscription.search", "app.api.endpoints.subscribe -> app.chain", "app.api.endpoints.subscribe -> app.chain.subscribe", - "app.api.endpoints.subscribe -> app.db", - "app.api.endpoints.subscribe -> app.db.models", - "app.api.endpoints.subscribe -> app.db.models.subscribe", - "app.api.endpoints.subscribe -> app.db.models.subscribehistory", - "app.api.endpoints.subscribe -> app.db.models.user", - "app.api.endpoints.subscribe -> app.db.oper", - "app.api.endpoints.subscribe -> app.db.oper.systemconfig", "app.api.endpoints.subscribe -> app.domain", "app.api.endpoints.subscribe -> app.domain.context", "app.api.endpoints.subscribe -> app.domain.metainfo", "app.api.endpoints.subscribe -> app.runtime", "app.api.endpoints.subscribe -> app.runtime.config", "app.api.endpoints.subscribe -> app.runtime.events", - "app.api.endpoints.subscribe -> app.scheduler", "app.api.endpoints.subscribe -> app.schemas", "app.api.endpoints.subscribe -> app.schemas.common", "app.api.endpoints.subscribe -> app.schemas.event", @@ -2180,19 +2071,25 @@ "app.api.endpoints.system -> app.adapters.network.http", "app.api.endpoints.system -> app.adapters.system", "app.api.endpoints.system -> app.adapters.system.rust", + "app.api.endpoints.system -> app.adapters.web", + "app.api.endpoints.system -> app.adapters.web.security", + "app.api.endpoints.system -> app.adapters.web.security.access", "app.api.endpoints.system -> app.agent", "app.api.endpoints.system -> app.agent.llm", "app.api.endpoints.system -> app.agent.llm.server_tools", "app.api.endpoints.system -> app.api", "app.api.endpoints.system -> app.api.deps", + "app.api.endpoints.system -> app.api.principal", "app.api.endpoints.system -> app.api.response", "app.api.endpoints.system -> app.application", + "app.api.endpoints.system -> app.application.configuration", "app.api.endpoints.system -> app.application.image", "app.api.endpoints.system -> app.application.messaging", "app.api.endpoints.system -> app.application.messaging.message", + "app.api.endpoints.system -> app.application.module", "app.api.endpoints.system -> app.application.rules", + "app.api.endpoints.system -> app.application.scheduling", "app.api.endpoints.system -> app.application.security", - "app.api.endpoints.system -> app.application.security.access", "app.api.endpoints.system -> app.application.security.url", "app.api.endpoints.system -> app.application.site", "app.api.endpoints.system -> app.chain", @@ -2200,10 +2097,6 @@ "app.api.endpoints.system -> app.chain.mediaserver", "app.api.endpoints.system -> app.chain.search", "app.api.endpoints.system -> app.chain.system", - "app.api.endpoints.system -> app.db", - "app.api.endpoints.system -> app.db.models", - "app.api.endpoints.system -> app.db.oper", - "app.api.endpoints.system -> app.db.oper.systemconfig", "app.api.endpoints.system -> app.domain", "app.api.endpoints.system -> app.domain.metainfo", "app.api.endpoints.system -> app.foundation", @@ -2212,13 +2105,10 @@ "app.api.endpoints.system -> app.runtime", "app.api.endpoints.system -> app.runtime.config", "app.api.endpoints.system -> app.runtime.events", - "app.api.endpoints.system -> app.runtime.extensions", - "app.api.endpoints.system -> app.runtime.extensions.module_manager", "app.api.endpoints.system -> app.runtime.localization", "app.api.endpoints.system -> app.runtime.log", "app.api.endpoints.system -> app.runtime.progress", "app.api.endpoints.system -> app.runtime.state", - "app.api.endpoints.system -> app.scheduler", "app.api.endpoints.system -> app.schemas", "app.api.endpoints.system -> app.schemas.common", "app.api.endpoints.system -> app.schemas.event", @@ -2226,19 +2116,17 @@ "app.api.endpoints.system -> app.schemas.system", "app.api.endpoints.system -> app.schemas.token", "app.api.endpoints.system -> app.schemas.types", + "app.api.endpoints.tmdb -> app.adapters", + "app.api.endpoints.tmdb -> app.adapters.web", + "app.api.endpoints.tmdb -> app.adapters.web.security", + "app.api.endpoints.tmdb -> app.adapters.web.security.access", "app.api.endpoints.tmdb -> app.api", "app.api.endpoints.tmdb -> app.api.deps", "app.api.endpoints.tmdb -> app.api.response", "app.api.endpoints.tmdb -> app.application", - "app.api.endpoints.tmdb -> app.application.security", - "app.api.endpoints.tmdb -> app.application.security.access", + "app.api.endpoints.tmdb -> app.application.configuration", "app.api.endpoints.tmdb -> app.chain", "app.api.endpoints.tmdb -> app.chain.tmdb", - "app.api.endpoints.tmdb -> app.db", - "app.api.endpoints.tmdb -> app.db.models", - "app.api.endpoints.tmdb -> app.db.models.user", - "app.api.endpoints.tmdb -> app.db.oper", - "app.api.endpoints.tmdb -> app.db.oper.systemconfig", "app.api.endpoints.tmdb -> app.runtime", "app.api.endpoints.tmdb -> app.runtime.config", "app.api.endpoints.tmdb -> app.schemas", @@ -2254,8 +2142,6 @@ "app.api.endpoints.torrent -> app.chain", "app.api.endpoints.torrent -> app.chain.media", "app.api.endpoints.torrent -> app.chain.torrents", - "app.api.endpoints.torrent -> app.db", - "app.api.endpoints.torrent -> app.db.models", "app.api.endpoints.torrent -> app.domain", "app.api.endpoints.torrent -> app.domain.context", "app.api.endpoints.torrent -> app.domain.media", @@ -2271,19 +2157,19 @@ "app.api.endpoints.torrent -> app.schemas.media", "app.api.endpoints.torrent -> app.schemas.response", "app.api.endpoints.torrent -> app.schemas.types", + "app.api.endpoints.transfer -> app.adapters", + "app.api.endpoints.transfer -> app.adapters.web", + "app.api.endpoints.transfer -> app.adapters.web.security", + "app.api.endpoints.transfer -> app.adapters.web.security.access", "app.api.endpoints.transfer -> app.api", "app.api.endpoints.transfer -> app.api.deps", "app.api.endpoints.transfer -> app.api.response", "app.api.endpoints.transfer -> app.application", "app.api.endpoints.transfer -> app.application.directory", - "app.api.endpoints.transfer -> app.application.security", - "app.api.endpoints.transfer -> app.application.security.access", + "app.api.endpoints.transfer -> app.application.history", "app.api.endpoints.transfer -> app.chain", "app.api.endpoints.transfer -> app.chain.media", "app.api.endpoints.transfer -> app.chain.transfer", - "app.api.endpoints.transfer -> app.db", - "app.api.endpoints.transfer -> app.db.models", - "app.api.endpoints.transfer -> app.db.models.transferhistory", "app.api.endpoints.transfer -> app.runtime", "app.api.endpoints.transfer -> app.runtime.config", "app.api.endpoints.transfer -> app.runtime.log", @@ -2300,21 +2186,19 @@ "app.api.endpoints.user -> app.api.response", "app.api.endpoints.user -> app.application", "app.api.endpoints.user -> app.application.security", - "app.api.endpoints.user -> app.application.security.access", - "app.api.endpoints.user -> app.db", - "app.api.endpoints.user -> app.db.models", - "app.api.endpoints.user -> app.db.models.user", - "app.api.endpoints.user -> app.db.oper", - "app.api.endpoints.user -> app.db.oper.userconfig", + "app.api.endpoints.user -> app.application.security.token", + "app.api.endpoints.user -> app.application.security.user", + "app.api.endpoints.user -> app.application.security.userconfig", "app.api.endpoints.user -> app.schemas", "app.api.endpoints.user -> app.schemas.common", "app.api.endpoints.user -> app.schemas.response", "app.api.endpoints.user -> app.schemas.user", + "app.api.endpoints.webhook -> app.adapters", + "app.api.endpoints.webhook -> app.adapters.web", + "app.api.endpoints.webhook -> app.adapters.web.security", + "app.api.endpoints.webhook -> app.adapters.web.security.access", "app.api.endpoints.webhook -> app.api", "app.api.endpoints.webhook -> app.api.response", - "app.api.endpoints.webhook -> app.application", - "app.api.endpoints.webhook -> app.application.security", - "app.api.endpoints.webhook -> app.application.security.access", "app.api.endpoints.webhook -> app.chain", "app.api.endpoints.webhook -> app.chain.webhook", "app.api.endpoints.webhook -> app.schemas", @@ -2326,16 +2210,11 @@ "app.api.endpoints.workflow -> app.api.deps", "app.api.endpoints.workflow -> app.api.response", "app.api.endpoints.workflow -> app.application", + "app.api.endpoints.workflow -> app.application.plugin", + "app.api.endpoints.workflow -> app.application.plugin.runtime", "app.api.endpoints.workflow -> app.application.workflow", "app.api.endpoints.workflow -> app.chain", "app.api.endpoints.workflow -> app.chain.workflow", - "app.api.endpoints.workflow -> app.db", - "app.api.endpoints.workflow -> app.db.models", - "app.api.endpoints.workflow -> app.db.oper", - "app.api.endpoints.workflow -> app.db.oper.workflow", - "app.api.endpoints.workflow -> app.runtime", - "app.api.endpoints.workflow -> app.runtime.extensions", - "app.api.endpoints.workflow -> app.runtime.extensions.plugin_manager", "app.api.endpoints.workflow -> app.schemas", "app.api.endpoints.workflow -> app.schemas.response", "app.api.endpoints.workflow -> app.schemas.types", @@ -2379,18 +2258,19 @@ "app.api.router_specs -> app.api.endpoints.user", "app.api.router_specs -> app.api.endpoints.webhook", "app.api.router_specs -> app.api.endpoints.workflow", + "app.api.servarr -> app.adapters", + "app.api.servarr -> app.adapters.web", + "app.api.servarr -> app.adapters.web.security", + "app.api.servarr -> app.adapters.web.security.access", "app.api.servarr -> app.api", + "app.api.servarr -> app.api.deps", "app.api.servarr -> app.api.response", "app.api.servarr -> app.application", - "app.api.servarr -> app.application.security", - "app.api.servarr -> app.application.security.access", + "app.api.servarr -> app.application.servarr", "app.api.servarr -> app.chain", "app.api.servarr -> app.chain.media", "app.api.servarr -> app.chain.subscribe", "app.api.servarr -> app.chain.tvdb", - "app.api.servarr -> app.db", - "app.api.servarr -> app.db.models", - "app.api.servarr -> app.db.models.subscribe", "app.api.servarr -> app.domain", "app.api.servarr -> app.domain.context", "app.api.servarr -> app.domain.metainfo", @@ -2416,23 +2296,15 @@ "app.application.audio -> app.schemas", "app.application.audio -> app.schemas.types", "app.application.chain.context -> app.application", - "app.application.chain.context -> app.application.messaging", - "app.application.chain.context -> app.application.messaging.message", - "app.application.chain.context -> app.db", - "app.application.chain.context -> app.db.oper", - "app.application.chain.context -> app.db.oper.message", - "app.application.chain.context -> app.runtime", - "app.application.chain.context -> app.runtime.cache", - "app.application.chain.context -> app.runtime.events", - "app.application.chain.context -> app.runtime.extensions", - "app.application.chain.context -> app.runtime.extensions.module_manager", - "app.application.chain.context -> app.runtime.extensions.plugin_manager", + "app.application.chain.context -> app.application.chain", + "app.application.chain.context -> app.application.chain.data", + "app.application.dashboard -> app.schemas", + "app.application.dashboard -> app.schemas.dashboard", "app.application.directory -> app.adapters", "app.application.directory -> app.adapters.system", "app.application.directory -> app.adapters.system.host", - "app.application.directory -> app.db", - "app.application.directory -> app.db.oper", - "app.application.directory -> app.db.oper.systemconfig", + "app.application.directory -> app.application", + "app.application.directory -> app.application.configuration", "app.application.directory -> app.domain", "app.application.directory -> app.domain.context", "app.application.directory -> app.runtime", @@ -2444,9 +2316,8 @@ "app.application.download.tasks -> app.schemas", "app.application.download.tasks -> app.schemas.transfer", "app.application.download.tasks -> app.schemas.types", - "app.application.downloader -> app.runtime", - "app.application.downloader -> app.runtime.extensions", - "app.application.downloader -> app.runtime.extensions.service_registry", + "app.application.downloader -> app.application", + "app.application.downloader -> app.application.service", "app.application.downloader -> app.schemas", "app.application.downloader -> app.schemas.system", "app.application.downloader -> app.schemas.types", @@ -2460,21 +2331,19 @@ "app.application.formatting -> app.schemas", "app.application.formatting -> app.schemas.transfer", "app.application.formatting -> app.schemas.workflow", - "app.application.history -> app.db", - "app.application.history -> app.db.models", - "app.application.history -> app.db.models.transferhistory", - "app.application.history -> app.db.oper", - "app.application.history -> app.db.oper.transferhistory", "app.application.history -> app.domain", "app.application.history -> app.domain.context", "app.application.history -> app.domain.meta", "app.application.history -> app.domain.meta.metabase", "app.application.history -> app.domain.meta.metamusic", + "app.application.history -> app.foundation", + "app.application.history -> app.foundation.text", "app.application.history -> app.runtime", "app.application.history -> app.runtime.cache", "app.application.history -> app.runtime.config", "app.application.history -> app.runtime.log", "app.application.history -> app.schemas", + "app.application.history -> app.schemas.history", "app.application.history -> app.schemas.media", "app.application.history -> app.schemas.transfer", "app.application.history -> app.schemas.types", @@ -2492,17 +2361,13 @@ "app.application.image -> app.runtime.cache", "app.application.image -> app.runtime.config", "app.application.image -> app.runtime.log", - "app.application.maintenance -> app.db", - "app.application.maintenance -> app.db.maintenance", - "app.application.maintenance -> app.db.session", "app.application.maintenance -> app.runtime", "app.application.maintenance -> app.runtime.config", "app.application.maintenance -> app.runtime.log", + "app.application.mediaserver -> app.application", + "app.application.mediaserver -> app.application.service", "app.application.mediaserver -> app.domain", "app.application.mediaserver -> app.domain.context", - "app.application.mediaserver -> app.runtime", - "app.application.mediaserver -> app.runtime.extensions", - "app.application.mediaserver -> app.runtime.extensions.service_registry", "app.application.mediaserver -> app.schemas", "app.application.mediaserver -> app.schemas.media", "app.application.mediaserver -> app.schemas.mediaserver", @@ -2510,6 +2375,8 @@ "app.application.mediaserver -> app.schemas.types", "app.application.messaging.agent -> app.schemas", "app.application.messaging.agent -> app.schemas.types", + "app.application.messaging.chat -> app.schemas", + "app.application.messaging.chat -> app.schemas.agent", "app.application.messaging.interaction -> app.schemas", "app.application.messaging.interaction -> app.schemas.message", "app.application.messaging.interaction -> app.schemas.notification", @@ -2520,9 +2387,8 @@ "app.application.messaging.media -> app.domain.meta.metabase", "app.application.messaging.media -> app.schemas", "app.application.messaging.media -> app.schemas.types", - "app.application.messaging.message -> app.db", - "app.application.messaging.message -> app.db.oper", - "app.application.messaging.message -> app.db.oper.systemconfig", + "app.application.messaging.message -> app.application", + "app.application.messaging.message -> app.application.configuration", "app.application.messaging.message -> app.domain", "app.application.messaging.message -> app.domain.context", "app.application.messaging.message -> app.domain.meta", @@ -2559,11 +2425,6 @@ "app.application.messaging.site -> app.application", "app.application.messaging.site -> app.application.messaging", "app.application.messaging.site -> app.application.messaging.interaction", - "app.application.messaging.site -> app.db", - "app.application.messaging.site -> app.db.models", - "app.application.messaging.site -> app.db.models.site", - "app.application.messaging.site -> app.db.oper", - "app.application.messaging.site -> app.db.oper.site", "app.application.messaging.site -> app.domain", "app.application.messaging.site -> app.domain.site", "app.application.messaging.site -> app.runtime", @@ -2571,26 +2432,15 @@ "app.application.messaging.site -> app.schemas", "app.application.messaging.site -> app.schemas.message", "app.application.messaging.site -> app.schemas.types", - "app.application.messaging.skill -> app.agent", - "app.application.messaging.skill -> app.agent.skills", - "app.application.messaging.skill -> app.agent.skills.registry", "app.application.messaging.skill -> app.application", "app.application.messaging.skill -> app.application.messaging", "app.application.messaging.skill -> app.application.messaging.interaction", "app.application.messaging.skill -> app.schemas", "app.application.messaging.skill -> app.schemas.message", "app.application.messaging.skill -> app.schemas.types", - "app.application.messaging.subscribe -> app.adapters", - "app.application.messaging.subscribe -> app.adapters.external", - "app.application.messaging.subscribe -> app.adapters.external.server", "app.application.messaging.subscribe -> app.application", "app.application.messaging.subscribe -> app.application.messaging", "app.application.messaging.subscribe -> app.application.messaging.interaction", - "app.application.messaging.subscribe -> app.db", - "app.application.messaging.subscribe -> app.db.models", - "app.application.messaging.subscribe -> app.db.models.subscribe", - "app.application.messaging.subscribe -> app.db.oper", - "app.application.messaging.subscribe -> app.db.oper.subscribe", "app.application.messaging.subscribe -> app.schemas", "app.application.messaging.subscribe -> app.schemas.message", "app.application.messaging.subscribe -> app.schemas.types", @@ -2601,32 +2451,21 @@ "app.application.music.catalog -> app.schemas", "app.application.music.catalog -> app.schemas.media", "app.application.music.catalog -> app.schemas.types", - "app.application.notification -> app.runtime", - "app.application.notification -> app.runtime.extensions", - "app.application.notification -> app.runtime.extensions.service_registry", + "app.application.notification -> app.application", + "app.application.notification -> app.application.service", "app.application.notification -> app.schemas", "app.application.notification -> app.schemas.system", "app.application.notification -> app.schemas.types", - "app.application.plugins -> app.adapters", - "app.application.plugins -> app.adapters.web", - "app.application.plugins -> app.adapters.web.plugin", - "app.application.plugins -> app.adapters.web.plugin.routes", "app.application.plugins -> app.application", - "app.application.plugins -> app.application.security", - "app.application.plugins -> app.application.security.access", - "app.application.plugins -> app.db", - "app.application.plugins -> app.db.oper", - "app.application.plugins -> app.db.oper.systemconfig", + "app.application.plugins -> app.application.configuration", + "app.application.plugins -> app.application.plugin", + "app.application.plugins -> app.application.plugin.routes", "app.application.plugins -> app.runtime", - "app.application.plugins -> app.runtime.config", - "app.application.plugins -> app.runtime.extensions", - "app.application.plugins -> app.runtime.extensions.plugin_manager", "app.application.plugins -> app.runtime.log", "app.application.plugins -> app.schemas", "app.application.plugins -> app.schemas.types", - "app.application.recognition -> app.db", - "app.application.recognition -> app.db.oper", - "app.application.recognition -> app.db.oper.systemconfig", + "app.application.recognition -> app.application", + "app.application.recognition -> app.application.configuration", "app.application.recognition -> app.schemas", "app.application.recognition -> app.schemas.types", "app.application.rss -> app.adapters", @@ -2641,9 +2480,8 @@ "app.application.rules -> app.adapters", "app.application.rules -> app.adapters.system", "app.application.rules -> app.adapters.system.rust", - "app.application.rules -> app.db", - "app.application.rules -> app.db.oper", - "app.application.rules -> app.db.oper.systemconfig", + "app.application.rules -> app.application", + "app.application.rules -> app.application.configuration", "app.application.rules -> app.domain", "app.application.rules -> app.domain.context", "app.application.rules -> app.schemas", @@ -2653,22 +2491,10 @@ "app.application.search.state -> app.schemas", "app.application.search.state -> app.schemas.media", "app.application.search.state -> app.schemas.types", - "app.application.security.access -> app.runtime", - "app.application.security.access -> app.runtime.cache", - "app.application.security.access -> app.runtime.config", - "app.application.security.access -> app.runtime.log", - "app.application.security.access -> app.schemas", - "app.application.security.access -> app.schemas.token", "app.application.security.auth -> app.application", "app.application.security.auth -> app.application.security", - "app.application.security.auth -> app.application.security.access", + "app.application.security.auth -> app.application.security.token", "app.application.security.auth -> app.application.site", - "app.application.security.auth -> app.db", - "app.application.security.auth -> app.db.models", - "app.application.security.auth -> app.db.models.user", - "app.application.security.auth -> app.db.oper", - "app.application.security.auth -> app.db.oper.systemconfig", - "app.application.security.auth -> app.db.oper.user", "app.application.security.auth -> app.foundation", "app.application.security.auth -> app.foundation.singleton", "app.application.security.auth -> app.runtime", @@ -2698,28 +2524,37 @@ "app.application.security.passkey -> app.runtime.cache", "app.application.security.passkey -> app.runtime.config", "app.application.security.passkey -> app.runtime.log", + "app.application.security.token -> app.runtime", + "app.application.security.token -> app.runtime.config", + "app.application.security.token -> app.runtime.log", + "app.application.security.token -> app.schemas", + "app.application.security.token -> app.schemas.token", "app.application.security.twofactor -> app.runtime", "app.application.security.twofactor -> app.runtime.log", "app.application.security.url -> app.runtime", "app.application.security.url -> app.runtime.coalesce", "app.application.security.url -> app.runtime.config", "app.application.security.url -> app.runtime.log", + "app.application.servarr -> app.schemas", + "app.application.servarr -> app.schemas.types", "app.application.server.report -> app.schemas", "app.application.server.report -> app.schemas.media", "app.application.server.share -> app.schemas", "app.application.server.share -> app.schemas.media", + "app.application.service -> app.schemas", + "app.application.service -> app.schemas.system", + "app.application.service -> app.schemas.types", "app.application.site.mutation -> app.application", "app.application.site.mutation -> app.application.subscription", "app.application.site.mutation -> app.application.subscription.delete", - "app.application.storage -> app.db", - "app.application.storage -> app.db.oper", - "app.application.storage -> app.db.oper.systemconfig", + "app.application.site.query -> app.schemas", + "app.application.site.query -> app.schemas.site", + "app.application.site.query -> app.schemas.workflow", + "app.application.storage -> app.application", + "app.application.storage -> app.application.configuration", "app.application.storage -> app.schemas", "app.application.storage -> app.schemas.system", "app.application.storage -> app.schemas.types", - "app.application.subscribe -> app.db", - "app.application.subscribe -> app.db.oper", - "app.application.subscribe -> app.db.oper.subscribe", "app.application.subscribe -> app.domain", "app.application.subscribe -> app.domain.context", "app.application.subscribe -> app.schemas", @@ -2745,16 +2580,17 @@ "app.application.subscription.query -> app.schemas", "app.application.subscription.query -> app.schemas.media", "app.application.subscription.query -> app.schemas.types", + "app.application.subscription.query -> app.schemas.workflow", "app.application.subscription.search -> app.application", "app.application.subscription.search -> app.application.subscription", "app.application.subscription.search -> app.application.subscription.delete", "app.application.torrent -> app.adapters", "app.application.torrent -> app.adapters.network", "app.application.torrent -> app.adapters.network.http", - "app.application.torrent -> app.db", - "app.application.torrent -> app.db.oper", - "app.application.torrent -> app.db.oper.site", - "app.application.torrent -> app.db.oper.systemconfig", + "app.application.torrent -> app.application", + "app.application.torrent -> app.application.configuration", + "app.application.torrent -> app.application.site", + "app.application.torrent -> app.application.site.query", "app.application.torrent -> app.domain", "app.application.torrent -> app.domain.context", "app.application.torrent -> app.domain.meta", @@ -2799,6 +2635,7 @@ "app.chain -> app.application", "app.chain -> app.application.chain", "app.chain -> app.application.chain.context", + "app.chain -> app.application.chain.data", "app.chain -> app.chain._messaging", "app.chain -> app.chain._recognition", "app.chain -> app.domain", @@ -2806,9 +2643,6 @@ "app.chain -> app.domain.meta", "app.chain -> app.domain.meta.metabase", "app.chain -> app.runtime", - "app.chain -> app.runtime.extensions", - "app.chain -> app.runtime.extensions.module", - "app.chain -> app.runtime.extensions.module.dispatcher", "app.chain -> app.runtime.log", "app.chain -> app.schemas", "app.chain -> app.schemas.category", @@ -2824,12 +2658,11 @@ "app.chain._interaction -> app.schemas", "app.chain._interaction -> app.schemas.types", "app.chain._messaging -> app.application", + "app.chain._messaging -> app.application.chain", + "app.chain._messaging -> app.application.chain.data", "app.chain._messaging -> app.application.messaging", "app.chain._messaging -> app.application.messaging.agent", "app.chain._messaging -> app.application.messaging.message", - "app.chain._messaging -> app.db", - "app.chain._messaging -> app.db.oper", - "app.chain._messaging -> app.db.oper.user", "app.chain._messaging -> app.domain", "app.chain._messaging -> app.domain.context", "app.chain._messaging -> app.domain.meta", @@ -2839,13 +2672,16 @@ "app.chain._messaging -> app.runtime", "app.chain._messaging -> app.runtime.config", "app.chain._messaging -> app.runtime.extensions", - "app.chain._messaging -> app.runtime.extensions.service_registry", + "app.chain._messaging -> app.runtime.extensions.service_config", "app.chain._messaging -> app.runtime.log", "app.chain._messaging -> app.schemas", "app.chain._messaging -> app.schemas.message", "app.chain._messaging -> app.schemas.transfer", "app.chain._messaging -> app.schemas.types", "app.chain._music -> app.application", + "app.chain._music -> app.application.chain", + "app.chain._music -> app.application.chain.data", + "app.chain._music -> app.application.configuration", "app.chain._music -> app.application.subscription", "app.chain._music -> app.application.subscription.contract", "app.chain._music -> app.application.torrent", @@ -2853,12 +2689,6 @@ "app.chain._music -> app.chain.download", "app.chain._music -> app.chain.media", "app.chain._music -> app.chain.search", - "app.chain._music -> app.db", - "app.chain._music -> app.db.models", - "app.chain._music -> app.db.models.subscribe", - "app.chain._music -> app.db.oper", - "app.chain._music -> app.db.oper.subscribe", - "app.chain._music -> app.db.oper.systemconfig", "app.chain._music -> app.domain", "app.chain._music -> app.domain.context", "app.chain._music -> app.domain.media", @@ -2871,9 +2701,8 @@ "app.chain._recognition -> app.adapters", "app.chain._recognition -> app.adapters.external", "app.chain._recognition -> app.adapters.external.server", - "app.chain._recognition -> app.db", - "app.chain._recognition -> app.db.oper", - "app.chain._recognition -> app.db.oper.systemconfig", + "app.chain._recognition -> app.application", + "app.chain._recognition -> app.application.configuration", "app.chain._recognition -> app.domain", "app.chain._recognition -> app.domain.context", "app.chain._recognition -> app.domain.meta", @@ -2892,6 +2721,9 @@ "app.chain._transfer -> app.adapters.system.host", "app.chain._transfer -> app.application", "app.chain._transfer -> app.application.agent", + "app.chain._transfer -> app.application.chain", + "app.chain._transfer -> app.application.chain.data", + "app.chain._transfer -> app.application.configuration", "app.chain._transfer -> app.application.formatting", "app.chain._transfer -> app.application.history", "app.chain._transfer -> app.application.transfer", @@ -2899,14 +2731,6 @@ "app.chain._transfer -> app.chain.media", "app.chain._transfer -> app.chain.storage", "app.chain._transfer -> app.chain.subscribe", - "app.chain._transfer -> app.db", - "app.chain._transfer -> app.db.models", - "app.chain._transfer -> app.db.models.downloadhistory", - "app.chain._transfer -> app.db.models.transferhistory", - "app.chain._transfer -> app.db.oper", - "app.chain._transfer -> app.db.oper.downloadhistory", - "app.chain._transfer -> app.db.oper.systemconfig", - "app.chain._transfer -> app.db.oper.transferhistory", "app.chain._transfer -> app.domain", "app.chain._transfer -> app.domain.context", "app.chain._transfer -> app.domain.media", @@ -2954,6 +2778,8 @@ "app.chain.download -> app.adapters.system", "app.chain.download -> app.adapters.system.host", "app.chain.download -> app.application", + "app.chain.download -> app.application.chain", + "app.chain.download -> app.application.chain.data", "app.chain.download -> app.application.directory", "app.chain.download -> app.application.download", "app.chain.download -> app.application.download.tasks", @@ -2961,13 +2787,6 @@ "app.chain.download -> app.chain", "app.chain.download -> app.chain.media", "app.chain.download -> app.chain.storage", - "app.chain.download -> app.db", - "app.chain.download -> app.db.models", - "app.chain.download -> app.db.models.downloadfailure", - "app.chain.download -> app.db.oper", - "app.chain.download -> app.db.oper.downloadfailure", - "app.chain.download -> app.db.oper.downloadhistory", - "app.chain.download -> app.db.oper.mediaserver", "app.chain.download -> app.domain", "app.chain.download -> app.domain.context", "app.chain.download -> app.domain.episode", @@ -2995,6 +2814,8 @@ "app.chain.download -> app.schemas.types", "app.chain.download -> app.schemas.workflow", "app.chain.interaction -> app.application", + "app.chain.interaction -> app.application.chain", + "app.chain.interaction -> app.application.chain.data", "app.chain.interaction -> app.application.directory", "app.chain.interaction -> app.application.messaging", "app.chain.interaction -> app.application.messaging.media", @@ -3004,9 +2825,6 @@ "app.chain.interaction -> app.chain.media", "app.chain.interaction -> app.chain.search", "app.chain.interaction -> app.chain.subscribe", - "app.chain.interaction -> app.db", - "app.chain.interaction -> app.db.oper", - "app.chain.interaction -> app.db.oper.user", "app.chain.interaction -> app.domain", "app.chain.interaction -> app.domain.context", "app.chain.interaction -> app.domain.episode", @@ -3067,16 +2885,15 @@ "app.chain.media -> app.schemas.media", "app.chain.media -> app.schemas.types", "app.chain.mediaserver -> app.application", + "app.chain.mediaserver -> app.application.chain", + "app.chain.mediaserver -> app.application.chain.data", "app.chain.mediaserver -> app.application.security", "app.chain.mediaserver -> app.application.security.url", "app.chain.mediaserver -> app.chain", - "app.chain.mediaserver -> app.db", - "app.chain.mediaserver -> app.db.oper", - "app.chain.mediaserver -> app.db.oper.mediaserver", "app.chain.mediaserver -> app.runtime", "app.chain.mediaserver -> app.runtime.config", "app.chain.mediaserver -> app.runtime.extensions", - "app.chain.mediaserver -> app.runtime.extensions.service_registry", + "app.chain.mediaserver -> app.runtime.extensions.service_config", "app.chain.mediaserver -> app.runtime.log", "app.chain.mediaserver -> app.schemas", "app.chain.mediaserver -> app.schemas.mediaserver", @@ -3140,13 +2957,11 @@ "app.chain.scraping -> app.adapters.network.http", "app.chain.scraping -> app.application", "app.chain.scraping -> app.application.audio", + "app.chain.scraping -> app.application.configuration", "app.chain.scraping -> app.chain", "app.chain.scraping -> app.chain.lrclib", "app.chain.scraping -> app.chain.media", "app.chain.scraping -> app.chain.storage", - "app.chain.scraping -> app.db", - "app.chain.scraping -> app.db.oper", - "app.chain.scraping -> app.db.oper.systemconfig", "app.chain.scraping -> app.domain", "app.chain.scraping -> app.domain.context", "app.chain.scraping -> app.domain.meta", @@ -3167,15 +2982,13 @@ "app.chain.scraping -> app.schemas.workflow", "app.chain.search -> app.application", "app.chain.search -> app.application.agent", + "app.chain.search -> app.application.configuration", "app.chain.search -> app.application.search", "app.chain.search -> app.application.search.state", "app.chain.search -> app.application.site", "app.chain.search -> app.application.torrent", "app.chain.search -> app.chain", "app.chain.search -> app.chain.media", - "app.chain.search -> app.db", - "app.chain.search -> app.db.oper", - "app.chain.search -> app.db.oper.systemconfig", "app.chain.search -> app.domain", "app.chain.search -> app.domain.context", "app.chain.search -> app.domain.meta", @@ -3201,6 +3014,9 @@ "app.chain.site -> app.adapters.network.cloudflare", "app.chain.site -> app.adapters.network.http", "app.chain.site -> app.application", + "app.chain.site -> app.application.chain", + "app.chain.site -> app.application.chain.data", + "app.chain.site -> app.application.configuration", "app.chain.site -> app.application.messaging", "app.chain.site -> app.application.messaging.site", "app.chain.site -> app.application.rss", @@ -3209,12 +3025,6 @@ "app.chain.site -> app.application.site", "app.chain.site -> app.chain", "app.chain.site -> app.chain._interaction", - "app.chain.site -> app.db", - "app.chain.site -> app.db.models", - "app.chain.site -> app.db.models.site", - "app.chain.site -> app.db.oper", - "app.chain.site -> app.db.oper.site", - "app.chain.site -> app.db.oper.systemconfig", "app.chain.site -> app.domain", "app.chain.site -> app.domain.site", "app.chain.site -> app.foundation", @@ -3242,6 +3052,9 @@ "app.chain.subscribe -> app.adapters.external", "app.chain.subscribe -> app.adapters.external.server", "app.chain.subscribe -> app.application", + "app.chain.subscribe -> app.application.chain", + "app.chain.subscribe -> app.application.chain.data", + "app.chain.subscribe -> app.application.configuration", "app.chain.subscribe -> app.application.mediaserver", "app.chain.subscribe -> app.application.messaging", "app.chain.subscribe -> app.application.messaging.subscribe", @@ -3259,14 +3072,6 @@ "app.chain.subscribe -> app.chain.search", "app.chain.subscribe -> app.chain.tmdb", "app.chain.subscribe -> app.chain.torrents", - "app.chain.subscribe -> app.db", - "app.chain.subscribe -> app.db.models", - "app.chain.subscribe -> app.db.models.subscribe", - "app.chain.subscribe -> app.db.oper", - "app.chain.subscribe -> app.db.oper.downloadhistory", - "app.chain.subscribe -> app.db.oper.site", - "app.chain.subscribe -> app.db.oper.subscribe", - "app.chain.subscribe -> app.db.oper.systemconfig", "app.chain.subscribe -> app.domain", "app.chain.subscribe -> app.domain.context", "app.chain.subscribe -> app.domain.meta", @@ -3291,11 +3096,12 @@ "app.chain.system -> app.adapters.network.http", "app.chain.system -> app.adapters.system", "app.chain.system -> app.adapters.system.host", + "app.chain.system -> app.application", + "app.chain.system -> app.application.plugin", + "app.chain.system -> app.application.plugin.runtime", "app.chain.system -> app.chain", "app.chain.system -> app.runtime", "app.chain.system -> app.runtime.config", - "app.chain.system -> app.runtime.extensions", - "app.chain.system -> app.runtime.extensions.plugin_manager", "app.chain.system -> app.runtime.log", "app.chain.system -> app.runtime.state", "app.chain.system -> app.schemas", @@ -3313,15 +3119,14 @@ "app.chain.tmdb -> app.schemas.tmdb", "app.chain.tmdb -> app.schemas.types", "app.chain.torrents -> app.application", + "app.chain.torrents -> app.application.chain", + "app.chain.torrents -> app.application.chain.data", + "app.chain.torrents -> app.application.configuration", "app.chain.torrents -> app.application.rss", "app.chain.torrents -> app.application.site", "app.chain.torrents -> app.application.torrent", "app.chain.torrents -> app.chain", "app.chain.torrents -> app.chain.media", - "app.chain.torrents -> app.db", - "app.chain.torrents -> app.db.oper", - "app.chain.torrents -> app.db.oper.site", - "app.chain.torrents -> app.db.oper.systemconfig", "app.chain.torrents -> app.domain", "app.chain.torrents -> app.domain.context", "app.chain.torrents -> app.domain.meta", @@ -3338,6 +3143,9 @@ "app.chain.torrents -> app.schemas.message", "app.chain.torrents -> app.schemas.types", "app.chain.transfer -> app.application", + "app.chain.transfer -> app.application.chain", + "app.chain.transfer -> app.application.chain.data", + "app.chain.transfer -> app.application.configuration", "app.chain.transfer -> app.application.directory", "app.chain.transfer -> app.application.formatting", "app.chain.transfer -> app.application.history", @@ -3347,14 +3155,6 @@ "app.chain.transfer -> app.chain.media", "app.chain.transfer -> app.chain.storage", "app.chain.transfer -> app.chain.tmdb", - "app.chain.transfer -> app.db", - "app.chain.transfer -> app.db.models", - "app.chain.transfer -> app.db.models.downloadhistory", - "app.chain.transfer -> app.db.oper", - "app.chain.transfer -> app.db.oper.downloadhistory", - "app.chain.transfer -> app.db.oper.systemconfig", - "app.chain.transfer -> app.db.oper.transferhistory", - "app.chain.transfer -> app.db.oper.transferpending", "app.chain.transfer -> app.domain", "app.chain.transfer -> app.domain.context", "app.chain.transfer -> app.domain.episode", @@ -3382,15 +3182,12 @@ "app.chain.transfer -> app.schemas.workflow", "app.chain.tvdb -> app.chain", "app.chain.user -> app.application", + "app.chain.user -> app.application.chain", + "app.chain.user -> app.application.chain.data", "app.chain.user -> app.application.security", - "app.chain.user -> app.application.security.access", "app.chain.user -> app.application.security.otp", + "app.chain.user -> app.application.security.token", "app.chain.user -> app.chain", - "app.chain.user -> app.db", - "app.chain.user -> app.db.models", - "app.chain.user -> app.db.models.user", - "app.chain.user -> app.db.oper", - "app.chain.user -> app.db.oper.user", "app.chain.user -> app.runtime", "app.chain.user -> app.runtime.config", "app.chain.user -> app.runtime.log", @@ -3400,11 +3197,10 @@ "app.chain.webhook -> app.chain", "app.chain.webhook -> app.schemas", "app.chain.webhook -> app.schemas.types", + "app.chain.workflow -> app.application", + "app.chain.workflow -> app.application.chain", + "app.chain.workflow -> app.application.chain.data", "app.chain.workflow -> app.chain", - "app.chain.workflow -> app.db", - "app.chain.workflow -> app.db.models", - "app.chain.workflow -> app.db.oper", - "app.chain.workflow -> app.db.oper.workflow", "app.chain.workflow -> app.runtime", "app.chain.workflow -> app.runtime.config", "app.chain.workflow -> app.runtime.events", @@ -3422,6 +3218,8 @@ "app.command -> app.application.messaging", "app.command -> app.application.messaging.message", "app.command -> app.application.messaging.skill", + "app.command -> app.application.plugin", + "app.command -> app.application.plugin.runtime", "app.command -> app.chain", "app.command -> app.chain.download", "app.command -> app.chain.message", @@ -3435,8 +3233,6 @@ "app.command -> app.foundation.singleton", "app.command -> app.runtime", "app.command -> app.runtime.events", - "app.command -> app.runtime.extensions", - "app.command -> app.runtime.extensions.plugin_manager", "app.command -> app.runtime.log", "app.command -> app.runtime.thread", "app.command -> app.scheduler", @@ -3459,6 +3255,8 @@ "app.db.engine -> app.runtime", "app.db.engine -> app.runtime.config", "app.db.engine -> app.runtime.log", + "app.db.health -> app.db", + "app.db.health -> app.db.session", "app.db.maintenance -> app.db", "app.db.maintenance -> app.db.models", "app.db.maintenance -> app.db.models.downloadfailure", @@ -3608,6 +3406,10 @@ "app.db.oper.message -> app.schemas", "app.db.oper.message -> app.schemas.message", "app.db.oper.message -> app.schemas.notification", + "app.db.oper.passkey -> app.db", + "app.db.oper.passkey -> app.db.base", + "app.db.oper.passkey -> app.db.models", + "app.db.oper.passkey -> app.db.models.passkey", "app.db.oper.plugindata -> app.db", "app.db.oper.plugindata -> app.db.base", "app.db.oper.plugindata -> app.db.models", @@ -3672,8 +3474,6 @@ "app.db.session -> app.runtime", "app.db.session -> app.runtime.config", "app.db.session -> app.runtime.log", - "app.doctor -> app.doctor.models", - "app.doctor -> app.doctor.runner", "app.doctor.checks -> app.adapters", "app.doctor.checks -> app.adapters.system", "app.doctor.checks -> app.adapters.system.host", @@ -3779,12 +3579,22 @@ "app.domain.title -> app.foundation.text", "app.domain.title -> app.schemas", "app.domain.title -> app.schemas.types", + "app.factory -> app.adapters", + "app.factory -> app.adapters.web", + "app.factory -> app.adapters.web.plugin", + "app.factory -> app.adapters.web.plugin.routes", + "app.factory -> app.adapters.web.security", + "app.factory -> app.adapters.web.security.access", "app.factory -> app.api", "app.factory -> app.api.response", "app.factory -> app.application", "app.factory -> app.application.plugins", + "app.factory -> app.application.security", + "app.factory -> app.application.security.token", "app.factory -> app.runtime", "app.factory -> app.runtime.config", + "app.factory -> app.runtime.extensions", + "app.factory -> app.runtime.extensions.plugin_manager", "app.factory -> app.runtime.localization", "app.factory -> app.runtime.log", "app.factory -> app.schemas", @@ -4021,9 +3831,8 @@ "app.modules.feishu.feishu -> app.application", "app.modules.feishu.feishu -> app.application.messaging", "app.modules.feishu.feishu -> app.application.messaging.agent", - "app.modules.feishu.feishu -> app.db", - "app.modules.feishu.feishu -> app.db.oper", - "app.modules.feishu.feishu -> app.db.oper.user", + "app.modules.feishu.feishu -> app.application.security", + "app.modules.feishu.feishu -> app.application.security.user", "app.modules.feishu.feishu -> app.domain", "app.modules.feishu.feishu -> app.domain.context", "app.modules.feishu.feishu -> app.runtime", @@ -4223,9 +4032,8 @@ "app.modules.filter -> app.schemas.types", "app.modules.indexer -> app.application", "app.modules.indexer -> app.application.site", - "app.modules.indexer -> app.db", - "app.modules.indexer -> app.db.oper", - "app.modules.indexer -> app.db.oper.site", + "app.modules.indexer -> app.application.site.health", + "app.modules.indexer -> app.application.site.query", "app.modules.indexer -> app.domain", "app.modules.indexer -> app.domain.context", "app.modules.indexer -> app.domain.site", @@ -4435,9 +4243,8 @@ "app.modules.indexer.spider.haidan -> app.adapters", "app.modules.indexer.spider.haidan -> app.adapters.network", "app.modules.indexer.spider.haidan -> app.adapters.network.http", - "app.modules.indexer.spider.haidan -> app.db", - "app.modules.indexer.spider.haidan -> app.db.oper", - "app.modules.indexer.spider.haidan -> app.db.oper.systemconfig", + "app.modules.indexer.spider.haidan -> app.application", + "app.modules.indexer.spider.haidan -> app.application.configuration", "app.modules.indexer.spider.haidan -> app.domain", "app.modules.indexer.spider.haidan -> app.domain.site", "app.modules.indexer.spider.haidan -> app.foundation", @@ -4450,9 +4257,8 @@ "app.modules.indexer.spider.hddolby -> app.adapters", "app.modules.indexer.spider.hddolby -> app.adapters.network", "app.modules.indexer.spider.hddolby -> app.adapters.network.http", - "app.modules.indexer.spider.hddolby -> app.db", - "app.modules.indexer.spider.hddolby -> app.db.oper", - "app.modules.indexer.spider.hddolby -> app.db.oper.systemconfig", + "app.modules.indexer.spider.hddolby -> app.application", + "app.modules.indexer.spider.hddolby -> app.application.configuration", "app.modules.indexer.spider.hddolby -> app.domain", "app.modules.indexer.spider.hddolby -> app.domain.site", "app.modules.indexer.spider.hddolby -> app.runtime", @@ -4463,9 +4269,8 @@ "app.modules.indexer.spider.mtorrent -> app.adapters", "app.modules.indexer.spider.mtorrent -> app.adapters.network", "app.modules.indexer.spider.mtorrent -> app.adapters.network.http", - "app.modules.indexer.spider.mtorrent -> app.db", - "app.modules.indexer.spider.mtorrent -> app.db.oper", - "app.modules.indexer.spider.mtorrent -> app.db.oper.systemconfig", + "app.modules.indexer.spider.mtorrent -> app.application", + "app.modules.indexer.spider.mtorrent -> app.application.configuration", "app.modules.indexer.spider.mtorrent -> app.domain", "app.modules.indexer.spider.mtorrent -> app.domain.site", "app.modules.indexer.spider.mtorrent -> app.foundation", @@ -4478,9 +4283,8 @@ "app.modules.indexer.spider.rousi -> app.adapters", "app.modules.indexer.spider.rousi -> app.adapters.network", "app.modules.indexer.spider.rousi -> app.adapters.network.http", - "app.modules.indexer.spider.rousi -> app.db", - "app.modules.indexer.spider.rousi -> app.db.oper", - "app.modules.indexer.spider.rousi -> app.db.oper.systemconfig", + "app.modules.indexer.spider.rousi -> app.application", + "app.modules.indexer.spider.rousi -> app.application.configuration", "app.modules.indexer.spider.rousi -> app.domain", "app.modules.indexer.spider.rousi -> app.domain.site", "app.modules.indexer.spider.rousi -> app.foundation", @@ -4665,7 +4469,8 @@ "app.modules.plex.plex -> app.schemas.dashboard", "app.modules.plex.plex -> app.schemas.mediaserver", "app.modules.plex.plex -> app.schemas.types", - "app.modules.postgresql -> app.db", + "app.modules.postgresql -> app.application", + "app.modules.postgresql -> app.application.database", "app.modules.postgresql -> app.modules", "app.modules.postgresql -> app.runtime", "app.modules.postgresql -> app.runtime.config", @@ -4693,23 +4498,6 @@ "app.modules.qbittorrent.qbittorrent -> app.foundation.url", "app.modules.qbittorrent.qbittorrent -> app.runtime", "app.modules.qbittorrent.qbittorrent -> app.runtime.log", - "app.modules.qqbot -> app.adapters", - "app.modules.qqbot -> app.adapters.network", - "app.modules.qqbot -> app.adapters.network.http", - "app.modules.qqbot -> app.application", - "app.modules.qqbot -> app.application.messaging", - "app.modules.qqbot -> app.application.messaging.agent", - "app.modules.qqbot -> app.domain", - "app.modules.qqbot -> app.domain.context", - "app.modules.qqbot -> app.modules", - "app.modules.qqbot -> app.modules._base", - "app.modules.qqbot -> app.modules.qqbot.qqbot", - "app.modules.qqbot -> app.runtime", - "app.modules.qqbot -> app.runtime.log", - "app.modules.qqbot -> app.schemas", - "app.modules.qqbot -> app.schemas.message", - "app.modules.qqbot -> app.schemas.notification", - "app.modules.qqbot -> app.schemas.types", "app.modules.qqbot.api -> app.adapters", "app.modules.qqbot.api -> app.adapters.network", "app.modules.qqbot.api -> app.adapters.network.http", @@ -4717,6 +4505,24 @@ "app.modules.qqbot.api -> app.runtime.log", "app.modules.qqbot.gateway -> app.runtime", "app.modules.qqbot.gateway -> app.runtime.log", + "app.modules.qqbot.module -> app.adapters", + "app.modules.qqbot.module -> app.adapters.network", + "app.modules.qqbot.module -> app.adapters.network.http", + "app.modules.qqbot.module -> app.application", + "app.modules.qqbot.module -> app.application.messaging", + "app.modules.qqbot.module -> app.application.messaging.agent", + "app.modules.qqbot.module -> app.domain", + "app.modules.qqbot.module -> app.domain.context", + "app.modules.qqbot.module -> app.modules", + "app.modules.qqbot.module -> app.modules._base", + "app.modules.qqbot.module -> app.modules.qqbot", + "app.modules.qqbot.module -> app.modules.qqbot.qqbot", + "app.modules.qqbot.module -> app.runtime", + "app.modules.qqbot.module -> app.runtime.log", + "app.modules.qqbot.module -> app.schemas", + "app.modules.qqbot.module -> app.schemas.message", + "app.modules.qqbot.module -> app.schemas.notification", + "app.modules.qqbot.module -> app.schemas.types", "app.modules.qqbot.qqbot -> app.adapters", "app.modules.qqbot.qqbot -> app.adapters.network", "app.modules.qqbot.qqbot -> app.adapters.network.http", @@ -4790,9 +4596,7 @@ "app.modules.subtitle -> app.adapters.network.http", "app.modules.subtitle -> app.application", "app.modules.subtitle -> app.application.site", - "app.modules.subtitle -> app.db", - "app.modules.subtitle -> app.db.oper", - "app.modules.subtitle -> app.db.oper.site", + "app.modules.subtitle -> app.application.site.query", "app.modules.subtitle -> app.domain", "app.modules.subtitle -> app.domain.context", "app.modules.subtitle -> app.modules", @@ -4829,21 +4633,22 @@ "app.modules.synologychat.synologychat -> app.foundation.url", "app.modules.synologychat.synologychat -> app.runtime", "app.modules.synologychat.synologychat -> app.runtime.log", - "app.modules.telegram -> app.application", - "app.modules.telegram -> app.application.messaging", - "app.modules.telegram -> app.application.messaging.agent", - "app.modules.telegram -> app.domain", - "app.modules.telegram -> app.domain.context", - "app.modules.telegram -> app.modules", - "app.modules.telegram -> app.modules._base", - "app.modules.telegram -> app.modules.telegram.telegram", - "app.modules.telegram -> app.runtime", - "app.modules.telegram -> app.runtime.log", - "app.modules.telegram -> app.schemas", - "app.modules.telegram -> app.schemas.message", - "app.modules.telegram -> app.schemas.notification", - "app.modules.telegram -> app.schemas.system", - "app.modules.telegram -> app.schemas.types", + "app.modules.telegram.module -> app.application", + "app.modules.telegram.module -> app.application.messaging", + "app.modules.telegram.module -> app.application.messaging.agent", + "app.modules.telegram.module -> app.domain", + "app.modules.telegram.module -> app.domain.context", + "app.modules.telegram.module -> app.modules", + "app.modules.telegram.module -> app.modules._base", + "app.modules.telegram.module -> app.modules.telegram", + "app.modules.telegram.module -> app.modules.telegram.telegram", + "app.modules.telegram.module -> app.runtime", + "app.modules.telegram.module -> app.runtime.log", + "app.modules.telegram.module -> app.schemas", + "app.modules.telegram.module -> app.schemas.message", + "app.modules.telegram.module -> app.schemas.notification", + "app.modules.telegram.module -> app.schemas.system", + "app.modules.telegram.module -> app.schemas.types", "app.modules.telegram.telegram -> app.adapters", "app.modules.telegram.telegram -> app.adapters.network", "app.modules.telegram.telegram -> app.adapters.network.http", @@ -5118,21 +4923,22 @@ "app.modules.transmission.transmission -> app.foundation.url", "app.modules.transmission.transmission -> app.runtime", "app.modules.transmission.transmission -> app.runtime.log", - "app.modules.trimemedia -> app.modules", - "app.modules.trimemedia -> app.modules._base", - "app.modules.trimemedia -> app.modules.trimemedia.trimemedia", - "app.modules.trimemedia -> app.runtime", - "app.modules.trimemedia -> app.runtime.log", - "app.modules.trimemedia -> app.schemas", - "app.modules.trimemedia -> app.schemas.dashboard", - "app.modules.trimemedia -> app.schemas.mediaserver", - "app.modules.trimemedia -> app.schemas.types", "app.modules.trimemedia.api -> app.adapters", "app.modules.trimemedia.api -> app.adapters.network", "app.modules.trimemedia.api -> app.adapters.network.http", "app.modules.trimemedia.api -> app.runtime", "app.modules.trimemedia.api -> app.runtime.config", "app.modules.trimemedia.api -> app.runtime.log", + "app.modules.trimemedia.module -> app.modules", + "app.modules.trimemedia.module -> app.modules._base", + "app.modules.trimemedia.module -> app.modules.trimemedia", + "app.modules.trimemedia.module -> app.modules.trimemedia.trimemedia", + "app.modules.trimemedia.module -> app.runtime", + "app.modules.trimemedia.module -> app.runtime.log", + "app.modules.trimemedia.module -> app.schemas", + "app.modules.trimemedia.module -> app.schemas.dashboard", + "app.modules.trimemedia.module -> app.schemas.mediaserver", + "app.modules.trimemedia.module -> app.schemas.types", "app.modules.trimemedia.trimemedia -> app.application", "app.modules.trimemedia.trimemedia -> app.application.mediaserver", "app.modules.trimemedia.trimemedia -> app.application.security", @@ -5148,15 +4954,6 @@ "app.modules.trimemedia.trimemedia -> app.schemas.dashboard", "app.modules.trimemedia.trimemedia -> app.schemas.mediaserver", "app.modules.trimemedia.trimemedia -> app.schemas.types", - "app.modules.ugreen -> app.modules", - "app.modules.ugreen -> app.modules._base", - "app.modules.ugreen -> app.modules.ugreen.ugreen", - "app.modules.ugreen -> app.runtime", - "app.modules.ugreen -> app.runtime.log", - "app.modules.ugreen -> app.schemas", - "app.modules.ugreen -> app.schemas.dashboard", - "app.modules.ugreen -> app.schemas.mediaserver", - "app.modules.ugreen -> app.schemas.types", "app.modules.ugreen.api -> app.foundation", "app.modules.ugreen.api -> app.foundation.url", "app.modules.ugreen.api -> app.modules", @@ -5164,11 +4961,19 @@ "app.modules.ugreen.api -> app.modules.ugreen.crypto", "app.modules.ugreen.api -> app.runtime", "app.modules.ugreen.api -> app.runtime.log", + "app.modules.ugreen.module -> app.modules", + "app.modules.ugreen.module -> app.modules._base", + "app.modules.ugreen.module -> app.modules.ugreen", + "app.modules.ugreen.module -> app.modules.ugreen.ugreen", + "app.modules.ugreen.module -> app.runtime", + "app.modules.ugreen.module -> app.runtime.log", + "app.modules.ugreen.module -> app.schemas", + "app.modules.ugreen.module -> app.schemas.dashboard", + "app.modules.ugreen.module -> app.schemas.mediaserver", + "app.modules.ugreen.module -> app.schemas.types", "app.modules.ugreen.ugreen -> app.application", + "app.modules.ugreen.ugreen -> app.application.configuration", "app.modules.ugreen.ugreen -> app.application.mediaserver", - "app.modules.ugreen.ugreen -> app.db", - "app.modules.ugreen.ugreen -> app.db.oper", - "app.modules.ugreen.ugreen -> app.db.oper.systemconfig", "app.modules.ugreen.ugreen -> app.foundation", "app.modules.ugreen.ugreen -> app.foundation.url", "app.modules.ugreen.ugreen -> app.modules", @@ -5311,8 +5116,6 @@ "app.modules.zspace.zspace -> app.schemas.dashboard", "app.modules.zspace.zspace -> app.schemas.mediaserver", "app.modules.zspace.zspace -> app.schemas.types", - "app.monitor -> app.monitor.monitor", - "app.monitor -> app.monitor.watcher", "app.monitor.dispatcher -> app.adapters", "app.monitor.dispatcher -> app.adapters.system", "app.monitor.dispatcher -> app.adapters.system.fsproxy", @@ -5321,9 +5124,6 @@ "app.monitor.dispatcher -> app.application.history", "app.monitor.dispatcher -> app.chain", "app.monitor.dispatcher -> app.chain.transfer", - "app.monitor.dispatcher -> app.db", - "app.monitor.dispatcher -> app.db.oper", - "app.monitor.dispatcher -> app.db.oper.transferhistory", "app.monitor.dispatcher -> app.runtime", "app.monitor.dispatcher -> app.runtime.cache", "app.monitor.dispatcher -> app.runtime.config", @@ -5397,10 +5197,8 @@ "app.runtime.compat.imports -> app.runtime.compat", "app.runtime.compat.imports -> app.runtime.compat.diagnostics", "app.runtime.compat.imports -> app.runtime.compat.manifest", - "app.runtime.config -> app.adapters", - "app.runtime.config -> app.adapters.system", - "app.runtime.config -> app.adapters.system.host", "app.runtime.config -> app.foundation", + "app.runtime.config -> app.foundation.environment", "app.runtime.config -> app.foundation.url", "app.runtime.config -> app.runtime", "app.runtime.config -> app.runtime.log", @@ -5416,6 +5214,7 @@ "app.runtime.event.dispatch -> app.runtime.event", "app.runtime.event.dispatch -> app.runtime.event.binding", "app.runtime.event.dispatch -> app.runtime.event.registry", + "app.runtime.event.dispatch -> app.runtime.execution", "app.runtime.event.dispatch -> app.runtime.log", "app.runtime.event.dispatch -> app.schemas", "app.runtime.event.dispatch -> app.schemas.types", @@ -5462,6 +5261,7 @@ "app.runtime.extensions.module.dispatcher -> app.foundation", "app.runtime.extensions.module.dispatcher -> app.foundation.reflection", "app.runtime.extensions.module.dispatcher -> app.runtime", + "app.runtime.extensions.module.dispatcher -> app.runtime.execution", "app.runtime.extensions.module.dispatcher -> app.runtime.extensions", "app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module", "app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module.contracts", @@ -5482,13 +5282,49 @@ "app.runtime.extensions.module_manager -> app.runtime.log", "app.runtime.extensions.module_manager -> app.schemas", "app.runtime.extensions.module_manager -> app.schemas.types", + "app.runtime.extensions.plugin.catalog -> app.foundation", + "app.runtime.extensions.plugin.catalog -> app.foundation.version", + "app.runtime.extensions.plugin.catalog -> app.runtime", + "app.runtime.extensions.plugin.catalog -> app.runtime.config", + "app.runtime.extensions.plugin.catalog -> app.runtime.extensions", + "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.storage", + "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.catalog -> app.schemas", + "app.runtime.extensions.plugin.catalog -> app.schemas.plugin", + "app.runtime.extensions.plugin.catalog -> app.schemas.types", "app.runtime.extensions.plugin.contracts -> app.foundation", "app.runtime.extensions.plugin.contracts -> app.foundation.reflection", + "app.runtime.extensions.plugin.dependency -> app.runtime", + "app.runtime.extensions.plugin.dependency -> app.runtime.extensions", + "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.metadata -> app.runtime", + "app.runtime.extensions.plugin.metadata -> app.runtime.extensions", + "app.runtime.extensions.plugin.metadata -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.metadata -> app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin.metadata -> app.schemas", + "app.runtime.extensions.plugin.metadata -> app.schemas.plugin", + "app.runtime.extensions.plugin.paths -> app.runtime", + "app.runtime.extensions.plugin.paths -> app.runtime.extensions", + "app.runtime.extensions.plugin.paths -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.paths -> app.runtime.extensions.plugin.system", "app.runtime.extensions.plugin.projection -> app.runtime", "app.runtime.extensions.plugin.projection -> app.runtime.extensions", "app.runtime.extensions.plugin.projection -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.projection -> app.runtime.extensions.plugin.contracts", "app.runtime.extensions.plugin.projection -> app.runtime.log", + "app.runtime.extensions.plugin.projection -> app.schemas", + "app.runtime.extensions.plugin.projection -> app.schemas.plugin", + "app.runtime.extensions.plugin.sync -> app.runtime", + "app.runtime.extensions.plugin.sync -> app.runtime.extensions", + "app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.tools -> app.runtime", + "app.runtime.extensions.plugin.tools -> app.runtime.extensions", + "app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin.contracts", "app.runtime.extensions.plugin_manager -> app.foundation", "app.runtime.extensions.plugin_manager -> app.foundation.crypto", "app.runtime.extensions.plugin_manager -> app.foundation.singleton", @@ -5498,11 +5334,21 @@ "app.runtime.extensions.plugin_manager -> app.runtime.events", "app.runtime.extensions.plugin_manager -> app.runtime.extensions", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.access", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.catalog", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.clone", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.dependency", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.lifecycle", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.metadata", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.monitor", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.paths", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.projection", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.registry", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.storage", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.sync", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.tools", "app.runtime.extensions.plugin_manager -> app.runtime.log", "app.runtime.extensions.plugin_manager -> app.runtime.reload", "app.runtime.extensions.plugin_manager -> app.schemas", @@ -5513,13 +5359,6 @@ "app.runtime.extensions.service_config -> app.schemas", "app.runtime.extensions.service_config -> app.schemas.system", "app.runtime.extensions.service_config -> app.schemas.types", - "app.runtime.extensions.service_registry -> app.runtime", - "app.runtime.extensions.service_registry -> app.runtime.extensions", - "app.runtime.extensions.service_registry -> app.runtime.extensions.module_manager", - "app.runtime.extensions.service_registry -> app.runtime.extensions.service_config", - "app.runtime.extensions.service_registry -> app.schemas", - "app.runtime.extensions.service_registry -> app.schemas.system", - "app.runtime.extensions.service_registry -> app.schemas.types", "app.runtime.progress -> app.runtime", "app.runtime.progress -> app.runtime.cache", "app.runtime.progress -> app.runtime.localization", @@ -5534,9 +5373,8 @@ "app.runtime.reload -> app.runtime.log", "app.runtime.reload -> app.schemas", "app.runtime.reload -> app.schemas.types", - "app.runtime.state -> app.adapters", - "app.runtime.state -> app.adapters.system", - "app.runtime.state -> app.adapters.system.host", + "app.runtime.state -> app.foundation", + "app.runtime.state -> app.foundation.environment", "app.runtime.state -> app.runtime", "app.runtime.state -> app.runtime.config", "app.runtime.state -> app.runtime.log", @@ -5575,7 +5413,7 @@ "app.scheduler -> app.runtime.events", "app.scheduler -> app.runtime.extensions", "app.scheduler -> app.runtime.extensions.plugin_manager", - "app.scheduler -> app.runtime.extensions.service_registry", + "app.scheduler -> app.runtime.extensions.service_config", "app.scheduler -> app.runtime.gc", "app.scheduler -> app.runtime.log", "app.scheduler -> app.runtime.progress", @@ -5746,15 +5584,23 @@ "app.sdk.plugins -> app.runtime.extensions", "app.sdk.plugins -> app.runtime.extensions.module_manager", "app.sdk.plugins -> app.runtime.extensions.plugin_manager", + "app.sdk.security -> app.adapters", + "app.sdk.security -> app.adapters.web", + "app.sdk.security -> app.adapters.web.security", + "app.sdk.security -> app.adapters.web.security.access", + "app.sdk.security -> app.application", + "app.sdk.security -> app.application.security", + "app.sdk.security -> app.application.security.token", "app.sdk.services -> app.application", "app.sdk.services -> app.application.downloader", "app.sdk.services -> app.application.mediaserver", "app.sdk.services -> app.application.notification", "app.sdk.services -> app.application.rules", + "app.sdk.services -> app.application.service", "app.sdk.services -> app.application.storage", "app.sdk.services -> app.runtime", "app.sdk.services -> app.runtime.extensions", - "app.sdk.services -> app.runtime.extensions.service_registry", + "app.sdk.services -> app.runtime.extensions.service_config", "app.sdk.services -> app.runtime.state", "app.sdk.string -> app.domain", "app.sdk.string -> app.domain.episode", @@ -5789,13 +5635,19 @@ "app.sdk.utilities -> app.sdk.string", "app.startup.agent_initializer -> app.agent", "app.startup.agent_initializer -> app.agent.llm", + "app.startup.agent_initializer -> app.agent.llm.gateway", + "app.startup.agent_initializer -> app.agent.llm.provider", "app.startup.agent_initializer -> app.agent.prompt", "app.startup.agent_initializer -> app.agent.prompt.transfer_redo", "app.startup.agent_initializer -> app.agent.runtime_loader", + "app.startup.agent_initializer -> app.agent.skills", + "app.startup.agent_initializer -> app.agent.skills.registry", "app.startup.agent_initializer -> app.agent.tools", "app.startup.agent_initializer -> app.agent.tools.base", "app.startup.agent_initializer -> app.application", "app.startup.agent_initializer -> app.application.agent", + "app.startup.agent_initializer -> app.application.messaging", + "app.startup.agent_initializer -> app.application.messaging.skill", "app.startup.agent_initializer -> app.runtime", "app.startup.agent_initializer -> app.runtime.config", "app.startup.agent_initializer -> app.runtime.events", @@ -5871,19 +5723,41 @@ "app.startup.modules_initializer -> app.adapters.system", "app.startup.modules_initializer -> app.adapters.system.host", "app.startup.modules_initializer -> app.adapters.system.resource", + "app.startup.modules_initializer -> app.adapters.web", + "app.startup.modules_initializer -> app.adapters.web.security", + "app.startup.modules_initializer -> app.adapters.web.security.access", + "app.startup.modules_initializer -> app.api", + "app.startup.modules_initializer -> app.api.data", "app.startup.modules_initializer -> app.application", + "app.startup.modules_initializer -> app.application.agentdata", "app.startup.modules_initializer -> app.application.chain", "app.startup.modules_initializer -> app.application.chain.context", + "app.startup.modules_initializer -> app.application.chain.data", + "app.startup.modules_initializer -> app.application.configuration", + "app.startup.modules_initializer -> app.application.database", + "app.startup.modules_initializer -> app.application.history", "app.startup.modules_initializer -> app.application.image", + "app.startup.modules_initializer -> app.application.maintenance", "app.startup.modules_initializer -> app.application.messaging", + "app.startup.modules_initializer -> app.application.messaging.chat", "app.startup.modules_initializer -> app.application.messaging.message", + "app.startup.modules_initializer -> app.application.module", + "app.startup.modules_initializer -> app.application.plugin", + "app.startup.modules_initializer -> app.application.plugin.runtime", "app.startup.modules_initializer -> app.application.security", - "app.startup.modules_initializer -> app.application.security.access", "app.startup.modules_initializer -> app.application.security.auth", + "app.startup.modules_initializer -> app.application.security.passkeys", + "app.startup.modules_initializer -> app.application.security.user", + "app.startup.modules_initializer -> app.application.security.userconfig", "app.startup.modules_initializer -> app.application.server", "app.startup.modules_initializer -> app.application.server.report", "app.startup.modules_initializer -> app.application.server.share", + "app.startup.modules_initializer -> app.application.service", "app.startup.modules_initializer -> app.application.site", + "app.startup.modules_initializer -> app.application.site.health", + "app.startup.modules_initializer -> app.application.site.query", + "app.startup.modules_initializer -> app.application.subscribe", + "app.startup.modules_initializer -> app.application.workflow", "app.startup.modules_initializer -> app.chain", "app.startup.modules_initializer -> app.chain.download", "app.startup.modules_initializer -> app.chain.mediaserver", @@ -5895,15 +5769,37 @@ "app.startup.modules_initializer -> app.chain.workflow", "app.startup.modules_initializer -> app.command", "app.startup.modules_initializer -> app.db", + "app.startup.modules_initializer -> app.db.health", + "app.startup.modules_initializer -> app.db.maintenance", "app.startup.modules_initializer -> app.db.oper", + "app.startup.modules_initializer -> app.db.oper.agentchat", + "app.startup.modules_initializer -> app.db.oper.agenttask", + "app.startup.modules_initializer -> app.db.oper.downloadfailure", + "app.startup.modules_initializer -> app.db.oper.downloadhistory", + "app.startup.modules_initializer -> app.db.oper.mediaserver", + "app.startup.modules_initializer -> app.db.oper.message", + "app.startup.modules_initializer -> app.db.oper.passkey", + "app.startup.modules_initializer -> app.db.oper.plugindata", + "app.startup.modules_initializer -> app.db.oper.site", "app.startup.modules_initializer -> app.db.oper.subscribe", + "app.startup.modules_initializer -> app.db.oper.subscribehistory", "app.startup.modules_initializer -> app.db.oper.systemconfig", + "app.startup.modules_initializer -> app.db.oper.transferhistory", + "app.startup.modules_initializer -> app.db.oper.transferpending", + "app.startup.modules_initializer -> app.db.oper.user", + "app.startup.modules_initializer -> app.db.oper.userconfig", "app.startup.modules_initializer -> app.db.oper.workflow", + "app.startup.modules_initializer -> app.db.session", + "app.startup.modules_initializer -> app.db.uow", "app.startup.modules_initializer -> app.runtime", + "app.startup.modules_initializer -> app.runtime.cache", "app.startup.modules_initializer -> app.runtime.config", "app.startup.modules_initializer -> app.runtime.events", "app.startup.modules_initializer -> app.runtime.extensions", + "app.startup.modules_initializer -> app.runtime.extensions.module", + "app.startup.modules_initializer -> app.runtime.extensions.module.dispatcher", "app.startup.modules_initializer -> app.runtime.extensions.module_manager", + "app.startup.modules_initializer -> app.runtime.extensions.plugin_manager", "app.startup.modules_initializer -> app.runtime.extensions.service_config", "app.startup.modules_initializer -> app.runtime.log", "app.startup.modules_initializer -> app.runtime.state", @@ -5973,10 +5869,9 @@ "app.testing.bootstrap -> app.startup.cache_initializer", "app.testing.bootstrap -> app.startup.database_initializer", "app.testing.bootstrap -> app.startup.domain_initializer", - "app.workflow -> app.db", - "app.workflow -> app.db.models", - "app.workflow -> app.db.oper", - "app.workflow -> app.db.oper.workflow", + "app.workflow -> app.application", + "app.workflow -> app.application.chain", + "app.workflow -> app.application.chain.data", "app.workflow -> app.foundation", "app.workflow -> app.foundation.reflection", "app.workflow -> app.foundation.singleton", @@ -5987,10 +5882,9 @@ "app.workflow -> app.schemas", "app.workflow -> app.schemas.types", "app.workflow -> app.schemas.workflow", + "app.workflow.actions -> app.application", + "app.workflow.actions -> app.application.configuration", "app.workflow.actions -> app.chain", - "app.workflow.actions -> app.db", - "app.workflow.actions -> app.db.oper", - "app.workflow.actions -> app.db.oper.systemconfig", "app.workflow.actions -> app.schemas", "app.workflow.actions -> app.schemas.workflow", "app.workflow.actions.add_download -> app.chain", @@ -6006,11 +5900,11 @@ "app.workflow.actions.add_download -> app.schemas.workflow", "app.workflow.actions.add_download -> app.workflow", "app.workflow.actions.add_download -> app.workflow.actions", + "app.workflow.actions.add_subscribe -> app.application", + "app.workflow.actions.add_subscribe -> app.application.chain", + "app.workflow.actions.add_subscribe -> app.application.chain.data", "app.workflow.actions.add_subscribe -> app.chain", "app.workflow.actions.add_subscribe -> app.chain.subscribe", - "app.workflow.actions.add_subscribe -> app.db", - "app.workflow.actions.add_subscribe -> app.db.oper", - "app.workflow.actions.add_subscribe -> app.db.oper.subscribe", "app.workflow.actions.add_subscribe -> app.domain", "app.workflow.actions.add_subscribe -> app.domain.context", "app.workflow.actions.add_subscribe -> app.runtime", @@ -6083,9 +5977,10 @@ "app.workflow.actions.filter_torrents -> app.schemas.workflow", "app.workflow.actions.filter_torrents -> app.workflow", "app.workflow.actions.filter_torrents -> app.workflow.actions", + "app.workflow.actions.invoke_plugin -> app.application", + "app.workflow.actions.invoke_plugin -> app.application.plugin", + "app.workflow.actions.invoke_plugin -> app.application.plugin.runtime", "app.workflow.actions.invoke_plugin -> app.runtime", - "app.workflow.actions.invoke_plugin -> app.runtime.extensions", - "app.workflow.actions.invoke_plugin -> app.runtime.extensions.plugin_manager", "app.workflow.actions.invoke_plugin -> app.runtime.log", "app.workflow.actions.invoke_plugin -> app.schemas", "app.workflow.actions.invoke_plugin -> app.schemas.workflow", @@ -6129,12 +6024,12 @@ "app.workflow.actions.send_message -> app.schemas.workflow", "app.workflow.actions.send_message -> app.workflow", "app.workflow.actions.send_message -> app.workflow.actions", + "app.workflow.actions.transfer_file -> app.application", + "app.workflow.actions.transfer_file -> app.application.chain", + "app.workflow.actions.transfer_file -> app.application.chain.data", "app.workflow.actions.transfer_file -> app.chain", "app.workflow.actions.transfer_file -> app.chain.storage", "app.workflow.actions.transfer_file -> app.chain.transfer", - "app.workflow.actions.transfer_file -> app.db", - "app.workflow.actions.transfer_file -> app.db.oper", - "app.workflow.actions.transfer_file -> app.db.oper.transferhistory", "app.workflow.actions.transfer_file -> app.runtime", "app.workflow.actions.transfer_file -> app.runtime.config", "app.workflow.actions.transfer_file -> app.runtime.log", @@ -6143,7 +6038,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 707, + "module_count": 746, "modules": [ "app", "app.adapters", @@ -6181,6 +6076,8 @@ "app.adapters.web", "app.adapters.web.plugin", "app.adapters.web.plugin.routes", + "app.adapters.web.security", + "app.adapters.web.security.access", "app.agent", "app.agent.callback", "app.agent.capabilities", @@ -6188,6 +6085,7 @@ "app.agent.contracts", "app.agent.llm", "app.agent.llm.capability", + "app.agent.llm.gateway", "app.agent.llm.helper", "app.agent.llm.provider", "app.agent.llm.server_tools", @@ -6323,6 +6221,7 @@ "app.agent.tools.tags", "app.api", "app.api.apiv1", + "app.api.data", "app.api.deps", "app.api.endpoints", "app.api.endpoints.agent", @@ -6359,16 +6258,22 @@ "app.api.endpoints.webhook", "app.api.endpoints.workflow", "app.api.openai_utils", + "app.api.principal", "app.api.response", "app.api.router_specs", "app.api.servarr", "app.api.servcookie", "app.application", "app.application.agent", + "app.application.agentdata", "app.application.audio", "app.application.chain", "app.application.chain.context", + "app.application.chain.data", "app.application.commands", + "app.application.configuration", + "app.application.dashboard", + "app.application.database", "app.application.directory", "app.application.download", "app.application.download.tasks", @@ -6380,6 +6285,7 @@ "app.application.mediaserver", "app.application.messaging", "app.application.messaging.agent", + "app.application.messaging.chat", "app.application.messaging.interaction", "app.application.messaging.media", "app.application.messaging.message", @@ -6389,6 +6295,7 @@ "app.application.messaging.site", "app.application.messaging.skill", "app.application.messaging.subscribe", + "app.application.module", "app.application.music", "app.application.music.catalog", "app.application.notification", @@ -6397,6 +6304,7 @@ "app.application.plugin.config", "app.application.plugin.install", "app.application.plugin.routes", + "app.application.plugin.runtime", "app.application.plugins", "app.application.recognition", "app.application.rss", @@ -6405,24 +6313,32 @@ "app.application.search", "app.application.search.state", "app.application.security", - "app.application.security.access", "app.application.security.auth", "app.application.security.cookie", "app.application.security.otp", "app.application.security.passkey", + "app.application.security.passkeys", + "app.application.security.token", "app.application.security.twofactor", "app.application.security.url", + "app.application.security.user", + "app.application.security.userconfig", + "app.application.servarr", "app.application.server", "app.application.server.report", "app.application.server.share", + "app.application.service", "app.application.site", + "app.application.site.health", "app.application.site.mutation", + "app.application.site.query", "app.application.storage", "app.application.subscribe", "app.application.subscription", "app.application.subscription.contract", "app.application.subscription.delete", "app.application.subscription.identity", + "app.application.subscription.mutation", "app.application.subscription.query", "app.application.subscription.search", "app.application.torrent", @@ -6471,6 +6387,7 @@ "app.db.decorators", "app.db.diagnostics", "app.db.engine", + "app.db.health", "app.db.maintenance", "app.db.models", "app.db.models._constraints", @@ -6503,6 +6420,7 @@ "app.db.oper.downloadhistory", "app.db.oper.mediaserver", "app.db.oper.message", + "app.db.oper.passkey", "app.db.oper.plugindata", "app.db.oper.site", "app.db.oper.subscribe", @@ -6546,6 +6464,7 @@ "app.foundation.collections", "app.foundation.crypto", "app.foundation.dom", + "app.foundation.environment", "app.foundation.identity", "app.foundation.reflection", "app.foundation.singleton", @@ -6634,6 +6553,7 @@ "app.modules.qqbot", "app.modules.qqbot.api", "app.modules.qqbot.gateway", + "app.modules.qqbot.module", "app.modules.qqbot.qqbot", "app.modules.redis", "app.modules.rtorrent", @@ -6645,6 +6565,7 @@ "app.modules.synologychat.synologychat", "app.modules.telegram", "app.modules.telegram.compat", + "app.modules.telegram.module", "app.modules.telegram.telegram", "app.modules.theaudiodb", "app.modules.themoviedb", @@ -6687,10 +6608,12 @@ "app.modules.transmission.transmission", "app.modules.trimemedia", "app.modules.trimemedia.api", + "app.modules.trimemedia.module", "app.modules.trimemedia.trimemedia", "app.modules.ugreen", "app.modules.ugreen.api", "app.modules.ugreen.crypto", + "app.modules.ugreen.module", "app.modules.ugreen.ugreen", "app.modules.vocechat", "app.modules.vocechat.vocechat", @@ -6740,14 +6663,24 @@ "app.runtime.extensions.module.dispatcher", "app.runtime.extensions.module_manager", "app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.access", + "app.runtime.extensions.plugin.catalog", + "app.runtime.extensions.plugin.clone", "app.runtime.extensions.plugin.contracts", + "app.runtime.extensions.plugin.dependency", + "app.runtime.extensions.plugin.lifecycle", + "app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin.metadata", + "app.runtime.extensions.plugin.monitor", + "app.runtime.extensions.plugin.paths", "app.runtime.extensions.plugin.projection", "app.runtime.extensions.plugin.registry", "app.runtime.extensions.plugin.storage", + "app.runtime.extensions.plugin.sync", "app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.tools", "app.runtime.extensions.plugin_manager", "app.runtime.extensions.service_config", - "app.runtime.extensions.service_registry", "app.runtime.gc", "app.runtime.localization", "app.runtime.log", @@ -6812,6 +6745,7 @@ "app.sdk.media", "app.sdk.network", "app.sdk.plugins", + "app.sdk.security", "app.sdk.services", "app.sdk.string", "app.sdk.utilities", @@ -6856,31 +6790,6 @@ "schema_version": 1, "scope": "MoviePilot host app excluding app/plugins", "strongly_connected_components": [ - [ - "app.agent.llm", - "app.agent.llm.capability", - "app.agent.llm.helper", - "app.agent.llm.provider" - ], - [ - "app.agent.policy", - "app.agent.policy.orchestrator", - "app.agent.policy.registry", - "app.agent.policy.sanitizer" - ], - [ - "app.doctor", - "app.doctor.checks", - "app.doctor.runner" - ], - [ - "app.modules.qqbot", - "app.modules.qqbot.qqbot" - ], - [ - "app.modules.telegram", - "app.modules.telegram.telegram" - ], [ "app.modules.themoviedb", "app.modules.themoviedb.scraper", @@ -6911,20 +6820,6 @@ "app.modules.themoviedb.tmdbv3api.objs.trending", "app.modules.themoviedb.tmdbv3api.objs.tv", "app.modules.themoviedb.tmdbv3api.tmdb" - ], - [ - "app.modules.trimemedia", - "app.modules.trimemedia.trimemedia" - ], - [ - "app.modules.ugreen", - "app.modules.ugreen.api", - "app.modules.ugreen.ugreen" - ], - [ - "app.monitor", - "app.monitor.monitor", - "app.monitor.poller" ] ] } diff --git a/tests/fixtures/architecture/official-plugin-baseline.json b/tests/fixtures/architecture/official-plugin-baseline.json index 37359c022..d62576c47 100644 --- a/tests/fixtures/architecture/official-plugin-baseline.json +++ b/tests/fixtures/architecture/official-plugin-baseline.json @@ -4831,7 +4831,7 @@ }, "schema_version": 2, "source": { - "head": "68340c0884c3aff4ee027c9c94ce25d096871aa5", + "head": "aa107b44a49bcaaa9f87d078fbf88da1971f722c", "python_file_count": 231, "repository": "MoviePilot-Plugins", "roots": [ diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 22c7c187b..c1492d364 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -151,9 +151,9 @@ "app.core.security": { "introduced": "v3.0.0", "is_package": false, - "owner": "application", - "replacement": "app.application.security.access", - "target": "app.application.security.access" + "owner": "sdk", + "replacement": "app.sdk.security", + "target": "app.sdk.security" }, "app.db.agentchat_oper": { "introduced": "v3.0.0", @@ -510,7 +510,7 @@ "is_package": false, "owner": "runtime", "replacement": "app.sdk.services", - "target": "app.runtime.extensions.service_registry" + "target": "app.sdk.services" }, "app.helper.sites": { "introduced": "v3.0.0", @@ -589,6 +589,13 @@ "replacement": "app.sdk.logging", "target": "app.sdk.logging" }, + "app.runtime.extensions.service_registry": { + "introduced": "v3.0.0", + "is_package": false, + "owner": "sdk", + "replacement": "app.sdk.services", + "target": "app.sdk.services" + }, "app.utils.coalesce": { "introduced": "v3.0.0", "is_package": false, @@ -1165,7 +1172,7 @@ }, { "caller": "app.chain.transfer", - "line": 608 + "line": 616 }, { "caller": "app.db.oper.transferpending", @@ -1277,7 +1284,7 @@ "producers": [ { "caller": "app.agent.orchestrator", - "line": 1272 + "line": 1277 } ] }, @@ -1363,7 +1370,7 @@ "producers": [ { "caller": "app.api.deps", - "line": 230 + "line": 384 } ] }, @@ -1385,7 +1392,7 @@ "producers": [ { "caller": "app.chain.download", - "line": 1052 + "line": 1056 } ] }, @@ -1394,7 +1401,7 @@ "producers": [ { "caller": "app.chain.download", - "line": 1419 + "line": 1423 } ] }, @@ -1403,11 +1410,11 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 1094 + "line": 1102 }, { "caller": "app.chain.transfer", - "line": 1110 + "line": 1118 } ] }, @@ -1416,7 +1423,7 @@ "producers": [ { "caller": "app.chain.subscribe", - "line": 2786 + "line": 2824 } ] }, @@ -1425,11 +1432,11 @@ "producers": [ { "caller": "app.chain.subscribe", - "line": 3524 + "line": 3567 }, { "caller": "app.chain.subscribe", - "line": 3546 + "line": 3589 } ] }, @@ -1487,7 +1494,7 @@ "producers": [ { "caller": "app.agent.orchestrator", - "line": 865 + "line": 870 } ] }, @@ -1496,7 +1503,7 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 388 + "line": 396 } ] }, @@ -1505,7 +1512,7 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 267 + "line": 275 } ] }, @@ -1539,7 +1546,7 @@ }, { "caller": "app.startup.agent_initializer", - "line": 91 + "line": 107 } ], "producers": [] @@ -1549,7 +1556,7 @@ "producers": [ { "caller": "app.chain.download", - "line": 1240 + "line": 1244 } ] }, @@ -1558,7 +1565,7 @@ "producers": [ { "caller": "app.chain.download", - "line": 2085 + "line": 2089 } ] }, @@ -1566,13 +1573,13 @@ "consumers": [ { "caller": "app.chain.download", - "line": 2069 + "line": 2073 } ], "producers": [ { "caller": "app.api.deps", - "line": 216 + "line": 370 } ] }, @@ -1611,11 +1618,11 @@ "producers": [ { "caller": "app.chain._transfer", - "line": 460 + "line": 464 }, { "caller": "app.chain._transfer", - "line": 635 + "line": 639 } ] }, @@ -1648,12 +1655,7 @@ "line": 1046 } ], - "producers": [ - { - "caller": "app.runtime.extensions.plugin_manager", - "line": 756 - } - ] + "producers": [] }, "EventType.PluginTriggered": { "consumers": [], @@ -1667,17 +1669,13 @@ }, { "caller": "app.chain.subscribe", - "line": 3001 + "line": 3044 } ], "producers": [ { "caller": "app.api.deps", - "line": 133 - }, - { - "caller": "app.api.endpoints.site", - "line": 199 + "line": 216 } ] }, @@ -1686,11 +1684,11 @@ "producers": [ { "caller": "app.chain.site", - "line": 74 + "line": 75 }, { "caller": "app.chain.site", - "line": 163 + "line": 164 } ] }, @@ -1698,15 +1696,15 @@ "consumers": [ { "caller": "app.chain.site", - "line": 586 + "line": 587 }, { "caller": "app.chain.site", - "line": 628 + "line": 629 }, { "caller": "app.chain.site", - "line": 650 + "line": 651 } ], "producers": [ @@ -1716,11 +1714,11 @@ }, { "caller": "app.api.deps", - "line": 128 + "line": 211 }, { "caller": "app.chain.site", - "line": 566 + "line": 567 } ] }, @@ -1729,11 +1727,11 @@ "producers": [ { "caller": "app.chain.subscribe", - "line": 996 + "line": 1034 }, { "caller": "app.chain.subscribe", - "line": 1200 + "line": 1238 } ] }, @@ -1742,7 +1740,7 @@ "producers": [ { "caller": "app.chain.subscribe", - "line": 2824 + "line": 2862 } ] }, @@ -1755,7 +1753,7 @@ }, { "caller": "app.api.deps", - "line": 64 + "line": 87 } ] }, @@ -1768,15 +1766,15 @@ }, { "caller": "app.api.endpoints.subscribe", - "line": 333 + "line": 296 }, { "caller": "app.api.endpoints.subscribe", - "line": 366 + "line": 329 }, { "caller": "app.api.endpoints.subscribe", - "line": 444 + "line": 389 } ] }, @@ -1785,7 +1783,7 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 374 + "line": 382 } ] }, @@ -1794,7 +1792,7 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 253 + "line": 261 } ] }, @@ -1803,11 +1801,11 @@ "producers": [ { "caller": "app.chain", - "line": 136 + "line": 137 }, { "caller": "app.chain", - "line": 162 + "line": 163 }, { "caller": "app.runtime.events", @@ -1824,7 +1822,7 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 360 + "line": 368 } ] }, @@ -1833,7 +1831,7 @@ "producers": [ { "caller": "app.chain.transfer", - "line": 239 + "line": 247 } ] }, @@ -1870,7 +1868,7 @@ ] } }, - "producer_count": 68 + "producer_count": 66 }, "run_module": { "call_count": 259, @@ -2026,7 +2024,7 @@ "async_bangumi_info": [ { "caller": "app.chain", - "line": 455, + "line": 456, "mode": "async" }, { @@ -2076,7 +2074,7 @@ "async_douban_info": [ { "caller": "app.chain", - "line": 392, + "line": 393, "mode": "async" } ], @@ -2132,7 +2130,7 @@ "async_match_doubaninfo": [ { "caller": "app.chain", - "line": 265, + "line": 266, "mode": "async" } ], @@ -2146,7 +2144,7 @@ "async_match_tmdbinfo": [ { "caller": "app.chain", - "line": 307, + "line": 308, "mode": "async" } ], @@ -2189,7 +2187,7 @@ "async_obtain_images": [ { "caller": "app.chain", - "line": 329, + "line": 330, "mode": "async" } ], @@ -2213,42 +2211,42 @@ "async_refresh_torrents": [ { "caller": "app.chain", - "line": 687, + "line": 688, "mode": "async" } ], "async_search_collections": [ { "caller": "app.chain", - "line": 561, + "line": 562, "mode": "async" } ], "async_search_medias": [ { "caller": "app.chain", - "line": 509, + "line": 510, "mode": "async" } ], "async_search_persons": [ { "caller": "app.chain", - "line": 535, + "line": 536, "mode": "async" } ], "async_search_subtitles": [ { "caller": "app.chain", - "line": 645, + "line": 646, "mode": "async" } ], "async_search_torrents": [ { "caller": "app.chain", - "line": 628, + "line": 629, "mode": "async" } ], @@ -2293,7 +2291,7 @@ "async_tmdb_info": [ { "caller": "app.chain", - "line": 437, + "line": 438, "mode": "async" } ], @@ -2451,7 +2449,7 @@ "bangumi_info": [ { "caller": "app.chain", - "line": 447, + "line": 448, "mode": "sync" }, { @@ -2491,7 +2489,7 @@ "clear_cache": [ { "caller": "app.chain", - "line": 1043, + "line": 1044, "mode": "sync" } ], @@ -2526,7 +2524,7 @@ "douban_info": [ { "caller": "app.chain", - "line": 372, + "line": 373, "mode": "sync" } ], @@ -2575,14 +2573,14 @@ "download": [ { "caller": "app.chain", - "line": 732, + "line": 733, "mode": "sync" } ], "download_added": [ { "caller": "app.chain", - "line": 756, + "line": 757, "mode": "sync" } ], @@ -2781,7 +2779,7 @@ "filter_torrents": [ { "caller": "app.chain", - "line": 704, + "line": 705, "mode": "sync" } ], @@ -2816,14 +2814,14 @@ "get_search_page_size": [ { "caller": "app.chain", - "line": 573, + "line": 574, "mode": "sync" } ], "get_torrent_trackers": [ { "caller": "app.chain", - "line": 950, + "line": 951, "mode": "sync" } ], @@ -2844,14 +2842,14 @@ "list_torrents": [ { "caller": "app.chain", - "line": 778, + "line": 779, "mode": "sync" } ], "load_category_config": [ { "caller": "app.chain", - "line": 1019, + "line": 1020, "mode": "sync" } ], @@ -2872,7 +2870,7 @@ "match_doubaninfo": [ { "caller": "app.chain", - "line": 237, + "line": 238, "mode": "sync" } ], @@ -2886,28 +2884,28 @@ "match_tmdbinfo": [ { "caller": "app.chain", - "line": 289, + "line": 290, "mode": "sync" } ], "media_category": [ { "caller": "app.chain", - "line": 1013, + "line": 1014, "mode": "sync" } ], "media_exists": [ { "caller": "app.chain", - "line": 980, + "line": 981, "mode": "sync" } ], "media_files": [ { "caller": "app.chain", - "line": 990, + "line": 991, "mode": "sync" } ], @@ -3003,14 +3001,14 @@ "message_parser": [ { "caller": "app.chain", - "line": 471, + "line": 472, "mode": "sync" } ], "metadata_img": [ { "caller": "app.chain", - "line": 1004, + "line": 1005, "mode": "sync" } ], @@ -3164,14 +3162,14 @@ "obtain_images": [ { "caller": "app.chain", - "line": 319, + "line": 320, "mode": "sync" } ], "obtain_specific_image": [ { "caller": "app.chain", - "line": 349, + "line": 350, "mode": "sync" } ], @@ -3195,40 +3193,40 @@ "recommend_name": [ { "caller": "app.chain._transfer", - "line": 668, + "line": 672, "mode": "sync" }, { "caller": "app.chain._transfer", - "line": 675, + "line": 679, "mode": "sync" } ], "refresh_torrents": [ { "caller": "app.chain", - "line": 666, + "line": 667, "mode": "sync" } ], "refresh_userdata": [ { "caller": "app.chain.site", - "line": 68, + "line": 69, "mode": "sync" } ], "register_commands": [ { "caller": "app.chain", - "line": 1031, + "line": 1032, "mode": "sync" } ], "remove_torrents": [ { "caller": "app.chain", - "line": 860, + "line": 861, "mode": "sync" } ], @@ -3242,28 +3240,28 @@ "save_category_config": [ { "caller": "app.chain", - "line": 1025, + "line": 1026, "mode": "sync" } ], "scheduler_job": [ { "caller": "app.chain", - "line": 1037, + "line": 1038, "mode": "sync" } ], "search_collections": [ { "caller": "app.chain", - "line": 548, + "line": 549, "mode": "sync" } ], "search_medias": [ { "caller": "app.chain", - "line": 496, + "line": 497, "mode": "sync" } ], @@ -3292,21 +3290,21 @@ "search_persons": [ { "caller": "app.chain", - "line": 522, + "line": 523, "mode": "sync" } ], "search_subtitles": [ { "caller": "app.chain", - "line": 609, + "line": 610, "mode": "sync" } ], "search_torrents": [ { "caller": "app.chain", - "line": 592, + "line": 593, "mode": "sync" } ], @@ -3327,14 +3325,14 @@ "set_torrents_tag": [ { "caller": "app.chain", - "line": 899, + "line": 900, "mode": "sync" } ], "site_subtitle_links": [ { "caller": "app.chain.download", - "line": 567, + "line": 571, "mode": "sync" } ], @@ -3348,14 +3346,14 @@ "start_torrents": [ { "caller": "app.chain", - "line": 876, + "line": 877, "mode": "sync" } ], "stop_torrents": [ { "caller": "app.chain", - "line": 887, + "line": 888, "mode": "sync" } ], @@ -3404,7 +3402,7 @@ "tmdb_episodes": [ { "caller": "app.chain._transfer", - "line": 661, + "line": 665, "mode": "sync" }, { @@ -3423,7 +3421,7 @@ "tmdb_info": [ { "caller": "app.chain", - "line": 425, + "line": 426, "mode": "sync" } ], @@ -3500,21 +3498,21 @@ "torrent_files": [ { "caller": "app.chain", - "line": 965, + "line": 966, "mode": "sync" } ], "transfer": [ { "caller": "app.chain", - "line": 821, + "line": 822, "mode": "sync" } ], "transfer_completed": [ { "caller": "app.chain", - "line": 845, + "line": 846, "mode": "sync" } ], @@ -3549,14 +3547,14 @@ "tvdb_info": [ { "caller": "app.chain", - "line": 405, + "line": 406, "mode": "sync" } ], "tvdb_slug": [ { "caller": "app.chain", - "line": 413, + "line": 414, "mode": "sync" } ], @@ -3570,7 +3568,7 @@ "update_torrent": [ { "caller": "app.chain", - "line": 926, + "line": 927, "mode": "sync" } ], @@ -3591,7 +3589,7 @@ "webhook_parser": [ { "caller": "app.chain", - "line": 485, + "line": 486, "mode": "sync" } ] @@ -3976,6 +3974,148 @@ "target": "app.runtime.extensions.plugin_manager.PluginManager" } ], + "app.sdk.security": [ + { + "kind": "import", + "name": "ALGORITHM", + "target": "app.application.security.token.ALGORITHM" + }, + { + "kind": "import", + "name": "BCRYPT_PASSWORD_MAX_BYTES", + "target": "app.application.security.token.BCRYPT_PASSWORD_MAX_BYTES" + }, + { + "kind": "import", + "name": "BCRYPT_ROUNDS", + "target": "app.application.security.token.BCRYPT_ROUNDS" + }, + { + "kind": "import", + "name": "PasswordTooLongError", + "target": "app.application.security.token.PasswordTooLongError" + }, + { + "kind": "import", + "name": "TokenValidationError", + "target": "app.application.security.token.TokenValidationError" + }, + { + "kind": "import", + "name": "aes_decrypt", + "target": "app.application.security.token.aes_decrypt" + }, + { + "kind": "import", + "name": "aes_encrypt", + "target": "app.application.security.token.aes_encrypt" + }, + { + "kind": "import", + "name": "anthropic_api_key_header", + "target": "app.adapters.web.security.access.anthropic_api_key_header" + }, + { + "kind": "import", + "name": "api_key_header", + "target": "app.adapters.web.security.access.api_key_header" + }, + { + "kind": "import", + "name": "api_key_query", + "target": "app.adapters.web.security.access.api_key_query" + }, + { + "kind": "import", + "name": "api_token_query", + "target": "app.adapters.web.security.access.api_token_query" + }, + { + "kind": "import", + "name": "create_access_token", + "target": "app.application.security.token.create_access_token" + }, + { + "kind": "import", + "name": "decode_access_token", + "target": "app.application.security.token.decode_access_token" + }, + { + "kind": "import", + "name": "decrypt", + "target": "app.application.security.token.decrypt" + }, + { + "kind": "import", + "name": "encrypt_message", + "target": "app.application.security.token.encrypt_message" + }, + { + "kind": "import", + "name": "get_password_hash", + "target": "app.application.security.token.get_password_hash" + }, + { + "kind": "import", + "name": "hash_sha256", + "target": "app.application.security.token.hash_sha256" + }, + { + "kind": "import", + "name": "nexusphp_encrypt", + "target": "app.application.security.token.nexusphp_encrypt" + }, + { + "kind": "import", + "name": "oauth2_scheme_manual_error", + "target": "app.adapters.web.security.access.oauth2_scheme_manual_error" + }, + { + "kind": "import", + "name": "openai_bearer_scheme", + "target": "app.adapters.web.security.access.openai_bearer_scheme" + }, + { + "kind": "import", + "name": "resource_token_cookie", + "target": "app.adapters.web.security.access.resource_token_cookie" + }, + { + "kind": "import", + "name": "set_or_refresh_resource_token_cookie", + "target": "app.adapters.web.security.access.set_or_refresh_resource_token_cookie" + }, + { + "kind": "import", + "name": "set_superuser_token_payload_provider", + "target": "app.adapters.web.security.access.set_superuser_token_payload_provider" + }, + { + "kind": "import", + "name": "verify_apikey", + "target": "app.adapters.web.security.access.verify_apikey" + }, + { + "kind": "import", + "name": "verify_apitoken", + "target": "app.adapters.web.security.access.verify_apitoken" + }, + { + "kind": "import", + "name": "verify_password", + "target": "app.application.security.token.verify_password" + }, + { + "kind": "import", + "name": "verify_resource_token", + "target": "app.adapters.web.security.access.verify_resource_token" + }, + { + "kind": "import", + "name": "verify_token", + "target": "app.adapters.web.security.access.verify_token" + } + ], "app.sdk.services": [ { "kind": "import", @@ -4010,12 +4150,12 @@ { "kind": "import", "name": "ServiceBaseHelper", - "target": "app.runtime.extensions.service_registry.ServiceBaseHelper" + "target": "app.application.service.ServiceBaseHelper" }, { "kind": "import", "name": "ServiceConfigHelper", - "target": "app.runtime.extensions.service_registry.ServiceConfigHelper" + "target": "app.runtime.extensions.service_config.ServiceConfigHelper" }, { "kind": "import", diff --git a/tests/test_agent_activity_log.py b/tests/test_agent_activity_log.py index f76836595..edc633ae6 100644 --- a/tests/test_agent_activity_log.py +++ b/tests/test_agent_activity_log.py @@ -482,7 +482,7 @@ def test_activity_log_provider_error_does_not_echo_secret(tmp_path): def test_factory_does_not_register_activity_log_tool(): """活动日志查询工具应由中间件注册,不应进入全局工具工厂。""" with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_agent_doctor_tool.py b/tests/test_agent_doctor_tool.py index 830b0c000..b4e51905c 100644 --- a/tests/test_agent_doctor_tool.py +++ b/tests/test_agent_doctor_tool.py @@ -43,7 +43,7 @@ def _doctor_report() -> DoctorReport: def test_factory_registers_doctor_report_tool(): """工具工厂应注册 doctor 诊断报告工具。""" with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_agent_download_task_tool_names.py b/tests/test_agent_download_task_tool_names.py index 381aa5b71..ad30e2815 100644 --- a/tests/test_agent_download_task_tool_names.py +++ b/tests/test_agent_download_task_tool_names.py @@ -8,7 +8,7 @@ def test_factory_registers_plural_download_task_tool_names(): 下载任务工具应统一使用 *_download_tasks 命名。 """ with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_agent_filter_rule_tools.py b/tests/test_agent_filter_rule_tools.py index 128fbe485..e9d479a6e 100644 --- a/tests/test_agent_filter_rule_tools.py +++ b/tests/test_agent_filter_rule_tools.py @@ -13,7 +13,7 @@ from app.agent.tools.impl.query_builtin_filter_rules import ( class TestAgentFilterRuleTools(unittest.TestCase): def test_factory_registers_filter_rule_tools(self): with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_agent_graph_cache.py b/tests/test_agent_graph_cache.py index 3561db338..ebba516dd 100644 --- a/tests/test_agent_graph_cache.py +++ b/tests/test_agent_graph_cache.py @@ -75,7 +75,7 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches(): "_agent_bundle_signature", new=AsyncMock(return_value=("sig",)), ), patch( - "app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision", + "app.agent.orchestrator._get_plugin_tools_revision", return_value=0, ), patch( "app.agent.orchestrator.agent_mcp_manager.config_signature", @@ -117,7 +117,7 @@ async def test_fresh_catalog_cache_hit_skips_tool_and_mcp_discovery() -> None: "_initialize_local_tool_catalogs", side_effect=AssertionError("tool catalog rebuilt"), ), patch( - "app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision", + "app.agent.orchestrator._get_plugin_tools_revision", return_value=0, ), patch( "app.agent.orchestrator.agent_mcp_manager.config_signature", @@ -197,7 +197,7 @@ async def test_expired_unchanged_catalog_renews_freshness() -> None: "app.agent.orchestrator.create_subagent_middlewares", return_value=([], []), ), patch( - "app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision", + "app.agent.orchestrator._get_plugin_tools_revision", return_value=0, ), patch( "app.agent.orchestrator.agent_mcp_manager.config_signature", @@ -412,7 +412,7 @@ async def test_graph_keeps_mcp_first_winner_and_catalogs_all_collisions( ), patch.object(agent, "_sync_model_profile"), patch( - "app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision", + "app.agent.orchestrator._get_plugin_tools_revision", return_value=0, ), patch( diff --git a/tests/test_agent_interaction.py b/tests/test_agent_interaction.py index 8e8388f83..82e2bb64c 100644 --- a/tests/test_agent_interaction.py +++ b/tests/test_agent_interaction.py @@ -55,7 +55,7 @@ class TestAgentInteraction(unittest.TestCase): def test_factory_injects_choice_tool_only_for_button_channels(self): with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): telegram_tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_agent_llm_capability.py b/tests/test_agent_llm_capability.py index 75bb6d76c..f786fe3ad 100644 --- a/tests/test_agent_llm_capability.py +++ b/tests/test_agent_llm_capability.py @@ -199,7 +199,7 @@ class AgentCapabilityManagerTest(unittest.TestCase): ] with patch( - "app.runtime.extensions.service_registry.ServiceConfigHelper.get_notification_configs", + "app.runtime.extensions.service_config.ServiceConfigHelper.get_notification_configs", return_value=configs, ): self.assertTrue( diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index b6e720e8d..acc9fae2b 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -118,7 +118,7 @@ def test_query_installed_plugins_fills_missing_repo_url_from_market() -> None: return_value=[installed_plugin], ), patch( - "app.agent.tools.impl._plugin_tool_utils.PluginManager", + "app.agent.tools.impl._plugin_tool_utils.get_plugin_manager", return_value=plugin_manager, ), ): @@ -147,7 +147,7 @@ def test_query_plugin_config_returns_saved_config_and_default_model() -> None: return_value=_plugin_snapshot(), ), patch( - "app.agent.tools.impl.query_plugin_config.PluginManager", + "app.agent.tools.impl.query_plugin_config.get_plugin_manager", return_value=plugin_manager, ), ): @@ -178,7 +178,7 @@ def test_update_plugin_config_merges_and_removes_keys_without_reloading() -> Non return_value=_plugin_snapshot(), ), patch( - "app.agent.tools.impl.update_plugin_config.PluginManager", + "app.agent.tools.impl.update_plugin_config.get_plugin_manager", return_value=plugin_manager, ), ): @@ -275,7 +275,7 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None: return_value=config_oper, ), patch( - "app.agent.tools.impl._plugin_tool_utils.PluginManager", + "app.agent.tools.impl._plugin_tool_utils.get_plugin_manager", return_value=plugin_manager, ), patch( diff --git a/tests/test_agent_recognize_captcha_tool.py b/tests/test_agent_recognize_captcha_tool.py index 3a386c5ce..4be76571e 100644 --- a/tests/test_agent_recognize_captcha_tool.py +++ b/tests/test_agent_recognize_captcha_tool.py @@ -30,7 +30,7 @@ class _FakeResponse: def test_factory_registers_recognize_captcha_tool(): """工具工厂应注册图形验证码识别工具。""" with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_agent_update_download_tasks_tool.py b/tests/test_agent_update_download_tasks_tool.py index c4c7883e9..6958587c7 100644 --- a/tests/test_agent_update_download_tasks_tool.py +++ b/tests/test_agent_update_download_tasks_tool.py @@ -196,7 +196,7 @@ def test_factory_registers_update_download_tasks_without_old_modify_name(): 工具工厂应只暴露统一后的下载任务更新工具名。 """ with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index d71dc92d5..2029bf43d 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -17,7 +17,7 @@ from app.api.endpoints import storage as storage_endpoint from app.api.endpoints import system as system_endpoint from app.api.endpoints import transfer as transfer_endpoint from app.api.endpoints import user as user_endpoint -from app.application.security.access import verify_resource_token +from app.adapters.web.security.access import verify_resource_token from app.api.deps import ( get_current_active_manage_user, get_current_active_manage_user_async, @@ -131,7 +131,11 @@ def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch): calls.append(key) return [{"path": "/downloads"}] - monkeypatch.setattr(system_endpoint, "SystemConfigOper", FakeSystemConfigOper) + monkeypatch.setattr( + system_endpoint, + "get_configured_system_config", + lambda: FakeSystemConfigOper(), + ) response = asyncio.run( system_endpoint.get_public_setting(SystemConfigKey.Directories.value) @@ -189,7 +193,11 @@ def test_login_sets_resource_token_cookie(monkeypatch): response = Response() monkeypatch.setattr(login_endpoint, "UserChain", FakeUserChain) - monkeypatch.setattr(login_endpoint, "SystemConfigOper", FakeSystemConfigOper) + monkeypatch.setattr( + login_endpoint, + "get_configured_system_config", + lambda: FakeSystemConfigOper(), + ) token = login_endpoint.login_access_token( request=request, @@ -275,9 +283,9 @@ def test_upload_avatar_rejects_other_user_for_non_superuser(): with pytest.raises(HTTPException) as exc_info: asyncio.run( - user_endpoint.upload_avatar( - user_id=2, - db=object(), + user_endpoint.upload_avatar( + user_id=2, + service=SimpleNamespace(), file=upload_file, current_user=current_user, ) @@ -290,34 +298,28 @@ def test_upload_avatar_rejects_other_user_for_non_superuser(): def test_upload_avatar_returns_filename_in_data(monkeypatch): """头像上传成功时应通过 data 返回文件名,message 只保留消息文本。""" - class FakeUser: - """记录头像更新内容的用户桩。""" + fake_user = SimpleNamespace() + current_user = SimpleNamespace(id=1, is_superuser=False) + upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png") + class FakeService: + """记录头像查询和更新的用户服务桩。""" - def __init__(self): - self.values = None - - async def async_update(self, db: object, values: dict[str, str]) -> None: - """记录待写入的头像数据。""" - self.values = values - - class FakeUserModel: - """返回固定用户的模型桩。""" - - @classmethod - async def async_get(cls, db: object, user_id: int) -> FakeUser: - """按用户 ID 返回测试用户。""" + async def get_by_id(self, user_id: int): + """按 ID 返回测试用户。""" assert user_id == 1 return fake_user - fake_user = FakeUser() - current_user = SimpleNamespace(id=1, is_superuser=False) - upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png") - monkeypatch.setattr(user_endpoint, "User", FakeUserModel) + async def update(self, user_id: int, values: dict[str, str]): + """记录用户头像更新。""" + assert user_id == 1 + fake_user.values = values + return fake_user + fake_service = FakeService() response = asyncio.run( user_endpoint.upload_avatar( user_id=1, - db=object(), + service=fake_service, file=upload_file, current_user=current_user, ) diff --git a/tests/test_api_response.py b/tests/test_api_response.py index 631ea9572..66c25d867 100644 --- a/tests/test_api_response.py +++ b/tests/test_api_response.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, ValidationError from starlette.responses import Response as StarletteResponse from starlette.responses import StreamingResponse +from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry from app.api.response import ( RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRoute, @@ -721,8 +722,17 @@ def test_plugin_routes_only_register_v1(monkeypatch): ] fake_app = FakeApp() - monkeypatch.setattr(plugins, "_api_app", fake_app) - monkeypatch.setattr(plugins, "PluginManager", FakePluginManager) + plugin_manager = FakePluginManager() + plugins.configure_plugin_routes(FastAPIDynamicRouteRegistry( + app=fake_app, + plugin_ids=lambda: ["DemoPlugin"], + plugin_apis=plugin_manager.get_plugin_apis, + verify_token=lambda: None, + verify_apikey=lambda: None, + prefix="/api/v1/plugin", + protected_routes=set(), + log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None), + )) plugins._update_plugin_api_routes("DemoPlugin", action="add") assert [route.path for route in fake_app.routes] == [ @@ -840,8 +850,17 @@ def build_plugin_api_app(monkeypatch) -> FastAPI: app = FastAPI() app.router.route_class = ResponseAPIRoute - monkeypatch.setattr(plugins, "_api_app", app) - monkeypatch.setattr(plugins, "PluginManager", FakePluginManager) + plugin_manager = FakePluginManager() + plugins.configure_plugin_routes(FastAPIDynamicRouteRegistry( + app=app, + plugin_ids=lambda: ["DemoPlugin"], + plugin_apis=plugin_manager.get_plugin_apis, + verify_token=lambda: None, + verify_apikey=lambda: None, + prefix="/api/v1/plugin", + protected_routes=set(), + log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None), + )) plugins._update_plugin_api_routes("DemoPlugin", action="add") return app diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index c0ae75c51..bd4670242 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -140,6 +140,96 @@ assert len(app.schemas.__all__) >= 400 assert result.returncode == 0, result.stderr +def test_agent_policy_root_import_does_not_eagerly_load_policy_submodules(): + """策略公共入口惰性导出,避免 orchestrator、registry 和 sanitizer 形成导入环。""" + script = """ +import sys +import app.agent.policy + +assert not any( + name.startswith('app.agent.policy.') + for name in sys.modules +) +from app.agent.policy import ToolOrigin, sanitize_for_host + +assert ToolOrigin.AGENT_API.value == 'agent_api' +assert sanitize_for_host({'token': 'secret'}) == {'token': '***'} +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_doctor_and_monitor_roots_are_lazy_identity_preserving_facades(): + """诊断和监控包根不得预载实现,旧路径仍需返回同一公开对象。""" + script = """ +import sys +import app.doctor +import app.monitor + +assert not any(name.startswith('app.doctor.') for name in sys.modules) +assert not any(name.startswith('app.monitor.') for name in sys.modules) + +from app.doctor import DoctorRunner, run_doctor +from app.doctor.runner import DoctorRunner as DirectDoctorRunner +from app.monitor import LocalDirectoryWatcher, Monitor +from app.monitor.monitor import Monitor as DirectMonitor +from app.monitor.watcher import LocalDirectoryWatcher as DirectWatcher + +assert DoctorRunner is DirectDoctorRunner +assert Monitor is DirectMonitor +assert LocalDirectoryWatcher is DirectWatcher +assert callable(run_doctor) +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_split_host_module_roots_keep_manifest_entrypoint_identity(): + """迁入 module.py 的宿主模块须保持 manifest 包级入口和历史反射路径。""" + script = """ +from importlib import import_module +import sys + +contracts = ( + ('app.modules.qqbot', 'QQBotModule'), + ('app.modules.telegram', 'TelegramModule'), + ('app.modules.trimemedia', 'TrimeMediaModule'), + ('app.modules.ugreen', 'UgreenModule'), +) +for package_name, symbol_name in contracts: + package = import_module(package_name) + implementation_name = f'{package_name}.module' + assert implementation_name not in sys.modules + public_class = getattr(package, symbol_name) + direct_class = getattr(import_module(implementation_name), symbol_name) + assert public_class is direct_class + assert public_class.__module__ == package_name +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + def test_event_contract_baseline_covers_every_public_event_enum() -> None: """事件生产者/消费者快照必须覆盖全部广播和链式事件枚举。""" baseline_path = BASELINE_ROOT / "runtime-contract-baseline.json" diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index a196d5f1c..eeac28818 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -2,9 +2,6 @@ import ast from functools import lru_cache from pathlib import Path -import pytest - - PROJECT_ROOT = Path(__file__).parents[1] APP_ROOT = PROJECT_ROOT / "app" LEGACY_ROOTS = ("app.core", "app.helper", "app.utils") @@ -100,13 +97,33 @@ FORBIDDEN_IMPORT_PREFIXES = { "app.sdk", ), "app.runtime": ( + "app.adapters", "app.application", "app.sdk", ), "app.application": ( + "app.runtime.extensions", "app.runtime.compat", "app.sdk", ), + "app.api": ( + "app.runtime.extensions.plugin_manager", + "app.runtime.extensions.module_manager", + "app.scheduler", + ), + "app.agent": ( + "app.runtime.extensions.plugin_manager", + "app.runtime.extensions.module_manager", + ), + "app.chain": ( + "app.runtime.extensions.plugin_manager", + "app.runtime.extensions.module_manager", + "app.runtime.extensions.module.dispatcher", + ), + "app.workflow": ( + "app.runtime.extensions.plugin_manager", + "app.runtime.extensions.module_manager", + ), } @@ -373,6 +390,33 @@ def test_database_internals_do_not_import_db_facades(): assert violations == [] +def test_entry_layers_do_not_import_database_implementations(): + """API、应用、编排、Agent、监控、模块和 Runtime 只能经端口访问持久化。""" + graph = _build_module_graph() + layer_roots = ( + "app.api", + "app.application", + "app.agent", + "app.chain", + "app.monitor", + "app.modules", + "app.runtime", + "app.workflow", + "app.adapters", + ) + violations = { + source: sorted( + dependency + for dependency in dependencies + if dependency.startswith("app.db") + ) + for source, dependencies in graph.items() + if source.startswith(layer_roots) + and any(dependency.startswith("app.db") for dependency in dependencies) + } + assert violations == {} + + def test_migrated_modules_are_not_in_import_cycles(): """任何 canonical 迁移模块都不得进入完整应用依赖图的环。""" modules = _discover_modules() @@ -437,6 +481,30 @@ def test_capability_packages_do_not_import_forbidden_upper_layers(): assert violations == {} +def test_application_does_not_import_transport_frameworks(): + """应用层不得依赖 FastAPI、Starlette 或宿主 HTTP 适配器。""" + violations: dict[str, set[str]] = {} + for path in (APP_ROOT / "application").rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + forbidden: set[str] = set() + for node in ast.walk(tree): + candidates: list[str] = [] + if isinstance(node, ast.Import): + candidates.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + candidates.append(node.module) + forbidden.update( + candidate + for candidate in candidates + if candidate.startswith( + ("fastapi", "starlette", "app.api", "app.adapters.web") + ) + ) + if forbidden: + violations[str(path.relative_to(PROJECT_ROOT))] = forbidden + assert violations == {} + + def test_site_domain_uses_foundation_dom_boundary(): """站点领域规则应依赖 DOM 原语,不得重新耦合聚合字符串工具。""" modules = _discover_modules() diff --git a/tests/test_chain_rate_limit.py b/tests/test_chain_rate_limit.py index 41d7df842..8f2e49cbd 100644 --- a/tests/test_chain_rate_limit.py +++ b/tests/test_chain_rate_limit.py @@ -11,6 +11,7 @@ setattr(sys.modules["transmission_rpc"], "File", object) from app.chain import ChainBase from app.application.chain.context import ChainRuntimeContext +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.schemas import RateLimitExceededException @@ -66,6 +67,7 @@ class ChainRateLimitTest(unittest.TestCase): file_cache=Mock(), async_file_cache=Mock(), message_queue_factory=lambda _callback: Mock(), + module_dispatcher_factory=ModuleInvocationDispatcher, ) ) return chain diff --git a/tests/test_chain_runtime_context.py b/tests/test_chain_runtime_context.py index bd5e94c2e..bfc8a02f6 100644 --- a/tests/test_chain_runtime_context.py +++ b/tests/test_chain_runtime_context.py @@ -5,6 +5,7 @@ from unittest.mock import Mock from app.application.chain.context import ChainRuntimeContext from app.application.chain import context as chain_context from app.chain import ChainBase +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher def _context() -> ChainRuntimeContext: @@ -18,6 +19,7 @@ def _context() -> ChainRuntimeContext: file_cache=Mock(), async_file_cache=Mock(), message_queue_factory=Mock(return_value=Mock()), + module_dispatcher_factory=ModuleInvocationDispatcher, ) diff --git a/tests/test_data_cleanup_chain.py b/tests/test_data_cleanup_chain.py index 4d82391c1..9573b4c85 100644 --- a/tests/test_data_cleanup_chain.py +++ b/tests/test_data_cleanup_chain.py @@ -14,6 +14,12 @@ from app.db.models.siteuserdata import SiteUserData from app.db.models.transferhistory import TransferHistory from app.runtime.config import settings from app.scheduler import SchedulerChain +from app.application.maintenance import ( + DataCleanupService, + configure_cleanup_service_factory, + read_cleanup_policy, +) +from app.db.maintenance import DatabaseCleanupRepository class DataCleanupChainTest(unittest.TestCase): @@ -44,6 +50,17 @@ class DataCleanupChainTest(unittest.TestCase): defaults.update(overrides) return patch.multiple(settings, **defaults) + def _configure_cleanup_service(self): + """把当前测试数据库注入清理应用服务,替代旧的 SessionFactory 打桩。""" + configure_cleanup_service_factory( + lambda: DataCleanupService( + repository=DatabaseCleanupRepository( + session_factory=self.SessionFactory, + ), + policy_reader=read_cleanup_policy, + ) + ) + def test_cleanup_removes_expired_rows_in_batches(self): """ 指定表应按保留期分批删除,并保留仍在有效期内的数据。 @@ -147,9 +164,8 @@ class DataCleanupChainTest(unittest.TestCase): ) db.commit() - with self._cleanup_settings(), patch( - "app.application.maintenance.SessionFactory", self.SessionFactory - ): + with self._cleanup_settings(): + self._configure_cleanup_service() report = SchedulerChain().cleanup(batch_size=1) self.assertEqual(report["tables"]["message"]["deleted"], 3) @@ -186,9 +202,8 @@ class DataCleanupChainTest(unittest.TestCase): ) db.commit() - with self._cleanup_settings(), patch( - "app.application.maintenance.SessionFactory", self.SessionFactory - ): + with self._cleanup_settings(): + self._configure_cleanup_service() report = SchedulerChain().cleanup(batch_size=10) self.assertEqual(report["tables"]["transferhistory"]["deleted"], 0) @@ -207,9 +222,8 @@ class DataCleanupChainTest(unittest.TestCase): db.add(Message(reg_time=old_message_time, title="old")) db.commit() - with self._cleanup_settings(DATA_CLEANUP_ENABLE=False), patch( - "app.application.maintenance.SessionFactory", self.SessionFactory - ): + with self._cleanup_settings(DATA_CLEANUP_ENABLE=False): + self._configure_cleanup_service() report = SchedulerChain().cleanup(batch_size=10) self.assertFalse(report["enabled"]) @@ -236,9 +250,8 @@ class DataCleanupChainTest(unittest.TestCase): ) db.commit() - with self._cleanup_settings(DATA_CLEANUP_MESSAGE_DAYS=7), patch( - "app.application.maintenance.SessionFactory", self.SessionFactory - ): + with self._cleanup_settings(DATA_CLEANUP_MESSAGE_DAYS=7): + self._configure_cleanup_service() report = SchedulerChain().cleanup(batch_size=10) self.assertEqual(report["tables"]["message"]["retention_days"], 7) @@ -274,9 +287,8 @@ class DataCleanupChainTest(unittest.TestCase): ) db.commit() - with self._cleanup_settings(DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS=0), patch( - "app.application.maintenance.SessionFactory", self.SessionFactory - ): + with self._cleanup_settings(DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS=0): + self._configure_cleanup_service() report = SchedulerChain().cleanup(batch_size=10) self.assertTrue(report["tables"]["downloadhistory"]["skipped"]) diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py index 40b54af89..963e9ad71 100644 --- a/tests/test_downloader_path_mapping.py +++ b/tests/test_downloader_path_mapping.py @@ -13,7 +13,7 @@ def _load_downloader_base(): app_module.__path__ = [] helper_module = types.ModuleType("app.helper") helper_module.__path__ = [] - service_module = types.ModuleType("app.runtime.extensions.service_registry") + service_module = types.ModuleType("app.runtime.extensions.service_config") schemas_module = types.ModuleType("app.schemas") schema_types_module = types.ModuleType("app.schemas.types") utils_module = types.ModuleType("app.utils") @@ -75,7 +75,7 @@ def _load_downloader_base(): stub_modules = { "app": app_module, "app.helper": helper_module, - "app.runtime.extensions.service_registry": service_module, + "app.runtime.extensions.service_config": service_module, "app.schemas": schemas_module, "app.schemas.types": schema_types_module, "app.utils": utils_module, diff --git a/tests/test_feedback_issue_scripts.py b/tests/test_feedback_issue_scripts.py index d7f42fb17..f641a79b1 100644 --- a/tests/test_feedback_issue_scripts.py +++ b/tests/test_feedback_issue_scripts.py @@ -189,7 +189,7 @@ class TestFeedbackIssueCommon(FeedbackIssueScriptTestCase): def test_factory_no_longer_registers_feedback_issue_tools(self): """Agent 工厂不应再注册 feedback-issue 专用工具。""" with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_history_query.py b/tests/test_history_query.py new file mode 100644 index 000000000..94c271218 --- /dev/null +++ b/tests/test_history_query.py @@ -0,0 +1,113 @@ +"""历史查询应用服务的分页、筛选和 DTO 边界测试。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.application.history import HistoryQueryService +from app.schemas.history import DownloadHistory, TransferHistory + + +def _make_service( + *, + downloads: AsyncMock | None = None, + transfers: AsyncMock | None = None, +) -> tuple[HistoryQueryService, AsyncMock, AsyncMock]: + """构造使用可观察异步仓储的历史查询服务。""" + download_repository = downloads or AsyncMock() + transfer_repository = transfers or AsyncMock() + return ( + HistoryQueryService( + download_repository=download_repository, + transfer_repository=transfer_repository, + ), + download_repository, + transfer_repository, + ) + + +@pytest.mark.asyncio +async def test_list_download_returns_schema_dtos() -> None: + """下载历史查询不得把仓储对象原样泄漏给 API。""" + service, download_repository, _ = _make_service() + raw_record = SimpleNamespace(id=7, title="Movie") + download_repository.async_list_by_page.return_value = [raw_record] + + records = await service.list_download(page=2, count=10) + + assert records == [DownloadHistory(id=7, title="Movie")] + assert records[0] is not raw_record + download_repository.async_list_by_page.assert_awaited_once_with(2, 10) + + +@pytest.mark.asyncio +async def test_list_transfer_maps_failed_alias_to_status_filter() -> None: + """“失败”快捷条件应继续映射为无标题的失败状态查询。""" + service, _, transfer_repository = _make_service() + transfer_repository.async_list_by_page.return_value = [ + SimpleNamespace(id=3, status=False) + ] + transfer_repository.async_count.return_value = 1 + + page = await service.list_transfer(title="失败", page=3, count=5) + + assert page.list == [TransferHistory(id=3, status=False)] + assert page.total == 1 + transfer_repository.async_list_by_page.assert_awaited_once_with( + page=3, + count=5, + status=False, + ) + transfer_repository.async_count.assert_awaited_once_with(status=False) + + +@pytest.mark.asyncio +async def test_list_transfer_preserves_glob_escaping() -> None: + """glob 查询应转义 SQL 通配符并显式启用 wildcard 模式。""" + service, _, transfer_repository = _make_service() + transfer_repository.async_list_by_title.return_value = [] + transfer_repository.async_count_by_title.return_value = 0 + + page = await service.list_transfer( + title=r"show_100%*.mkv", + page=1, + count=30, + status=True, + ) + + assert page.total == 0 + pattern = r"show\_100\%%.mkv" + transfer_repository.async_count_by_title.assert_awaited_once_with( + pattern, + status=True, + wildcard=True, + ) + transfer_repository.async_list_by_title.assert_awaited_once_with( + pattern, + page=1, + count=30, + status=True, + wildcard=True, + ) + + +@pytest.mark.asyncio +async def test_get_transfers_preserves_order_and_reports_missing_ids() -> None: + """批量 AI 重做准备应保持去重后的输入顺序并报告缺失记录。""" + service, _, transfer_repository = _make_service() + transfer_repository.async_get.side_effect = [ + SimpleNamespace(id=11), + None, + SimpleNamespace(id=13), + ] + + records, missing_ids = await service.get_transfers([11, 12, 13]) + + assert [record.id for record in records] == [11, 13] + assert missing_ids == [12] + assert transfer_repository.async_get.await_args_list == [ + ((11,), {}), + ((12,), {}), + ((13,), {}), + ] diff --git a/tests/test_llm_helper_testcall.py b/tests/test_llm_helper_testcall.py index 4b2ad7194..a31072e89 100644 --- a/tests/test_llm_helper_testcall.py +++ b/tests/test_llm_helper_testcall.py @@ -2,6 +2,7 @@ import asyncio import importlib.util import sys import unittest +from contextlib import contextmanager from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock, patch @@ -195,36 +196,29 @@ class _OfflineProviderManager: } -class _OfflineProviderError(RuntimeError): - """离线 provider 替身使用的兼容异常类型。""" +@contextmanager +def _use_provider_runtime(manager_cls): + """在当前测试块内通过正式端口注入 provider 运行时替身。""" + from app.agent.llm.gateway import register_llm_provider_runtime - -def _render_offline_auth_result(*_args, **_kwargs): - """满足 LLM provider 包导出的最小 HTML renderer 契约。""" - return "" - - -def _build_provider_module(manager_cls): - """构造满足 ``app.agent.llm`` 包导入契约的 provider 替身。""" - provider_module = ModuleType("app.agent.llm.provider") - provider_module.LLMProviderManager = manager_cls - provider_module.LLMProviderError = _OfflineProviderError - provider_module.LLMProviderAuthError = _OfflineProviderError - provider_module.render_auth_result_html = _render_offline_auth_result - return provider_module + previous = register_llm_provider_runtime(lambda: manager_cls()) + try: + yield + finally: + register_llm_provider_runtime(previous) class LlmHelperTestCallTest(unittest.TestCase): def setUp(self): """为每个用例默认注入离线 provider,确保 get_llm 不会真访问 models.dev。 - 需要校验特定 resolve_runtime 行为的用例,可在自身 patch.dict 中再覆盖 - ``sys.modules['app.agent.llm.provider']``;用例结束后由 addCleanup 还原。 + 需要校验特定 resolve_runtime 行为的用例,可在自身注册专用运行时; + 用例结束后恢复测试进程先前的组合配置。 """ - provider_module = _build_provider_module(_OfflineProviderManager) - patcher = patch.dict(sys.modules, {"app.agent.llm.provider": provider_module}) - patcher.start() - self.addCleanup(patcher.stop) + from app.agent.llm.gateway import register_llm_provider_runtime + + previous = register_llm_provider_runtime(lambda: _OfflineProviderManager()) + self.addCleanup(register_llm_provider_runtime, previous) def test_normalize_model_profile_fills_partial_profile_from_provider_record(self): profile = llm_module.LLMHelper._normalize_model_profile( @@ -819,11 +813,12 @@ class LlmHelperTestCallTest(unittest.TestCase): self.model = kwargs["model"] self.profile = None - provider_module = _build_provider_module(_FakeProviderManager) openai_module = ModuleType("langchain_openai") openai_module.ChatOpenAI = _FakeChatOpenAI - with patch.object(llm_module.settings, "LLM_PROVIDER", "deepseek"), patch.object( + with _use_provider_runtime(_FakeProviderManager), patch.object( + llm_module.settings, "LLM_PROVIDER", "deepseek" + ), patch.object( llm_module.settings, "LLM_MODEL", "deepseek-chat" ), patch.object(llm_module.settings, "LLM_API_KEY", "updated-key"), patch.object( llm_module.settings, "LLM_BASE_URL", "https://updated.example.com/v1" @@ -832,7 +827,6 @@ class LlmHelperTestCallTest(unittest.TestCase): ), patch.dict( sys.modules, { - "app.agent.llm.provider": provider_module, "langchain_openai": openai_module, }, ): @@ -882,14 +876,12 @@ class LlmHelperTestCallTest(unittest.TestCase): self.model = kwargs["model"] self.profile = None - provider_module = _build_provider_module(_FakeProviderManager) anthropic_module = ModuleType("langchain_anthropic") anthropic_module.ChatAnthropic = _FakeChatAnthropic - with patch.dict( + with _use_provider_runtime(_FakeProviderManager), patch.dict( sys.modules, { - "app.agent.llm.provider": provider_module, "langchain_anthropic": anthropic_module, }, ): @@ -996,13 +988,11 @@ class LlmHelperTestCallTest(unittest.TestCase): "model_metadata": {}, } - provider_module = _build_provider_module(_FakeProviderManager) fake_openai_modules, _ = _build_fake_openai_modules() - with patch.dict( + with _use_provider_runtime(_FakeProviderManager), patch.dict( sys.modules, { - "app.agent.llm.provider": provider_module, **fake_openai_modules, }, ): @@ -1245,12 +1235,9 @@ class LlmHelperTestCallTest(unittest.TestCase): self.model = kwargs["model"] self.profile = None - provider_module = _build_provider_module(_FakeProviderManager) - - with patch.dict( + with _use_provider_runtime(_FakeProviderManager), patch.dict( sys.modules, { - "app.agent.llm.provider": provider_module, "langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI), }, ): diff --git a/tests/test_login_mfa_methods.py b/tests/test_login_mfa_methods.py index 21b49a537..29e517187 100644 --- a/tests/test_login_mfa_methods.py +++ b/tests/test_login_mfa_methods.py @@ -147,11 +147,6 @@ def test_passkey_authentication_start_returns_object_options(monkeypatch): def test_passkey_registration_start_returns_object_options(monkeypatch): """Passkey 注册选项应作为对象返回,避免统一响应模型校验失败。""" - monkeypatch.setattr( - mfa_endpoint.PassKey, - "get_by_user_id", - staticmethod(lambda **_: []), - ) monkeypatch.setattr( mfa_endpoint.PassKeyHelper, "generate_registration_options", @@ -166,7 +161,10 @@ def test_passkey_registration_start_returns_object_options(monkeypatch): ) user = SimpleNamespace(id=1, name="user", settings={}) - response = mfa_endpoint.passkey_register_start(current_user=user) + response = mfa_endpoint.passkey_register_start( + current_user=user, + service=SimpleNamespace(list_by_user_id=lambda user_id: []), + ) payload = schemas.PasskeyStartData.model_validate(response.data) assert response.success is True diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py index 12665826e..9e5d90c27 100644 --- a/tests/test_manual_transfer_history.py +++ b/tests/test_manual_transfer_history.py @@ -13,7 +13,7 @@ from app.application.history import ( max_failed_retries, record_transfer_failure, ) -from app.schemas import FileItem, ManualTransferItem +from app.schemas import ManualTransferItem from tests.test_transfer_sync_extra_files import ( FakeMeta, make_fileitem, @@ -79,10 +79,10 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -126,7 +126,7 @@ def test_query_manual_transfer_history_returns_success_summary(monkeypatch): response = query_manual_transfer_history( transer_item=ManualTransferItem(fileitem=fileitem), - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -159,7 +159,7 @@ def test_manual_transfer_endpoint_passes_reorganize_confirmation(monkeypatch): reorganize=True, ), background=False, - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -188,10 +188,6 @@ def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch): captured.update(kwargs) return True, "" - monkeypatch.setattr( - "app.api.endpoints.transfer.TransferHistory.get", - lambda db, logid: history, - ) monkeypatch.setattr( "app.api.endpoints.transfer.TransferChain", _FakeTransferChain, @@ -203,7 +199,7 @@ def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch): reorganize=True, ), background=False, - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: history), _="token", ) diff --git a/tests/test_media_recognize_share_statistics.py b/tests/test_media_recognize_share_statistics.py index 23a159a65..6f920c66f 100644 --- a/tests/test_media_recognize_share_statistics.py +++ b/tests/test_media_recognize_share_statistics.py @@ -31,7 +31,7 @@ def _mock_counter(monkeypatch) -> Mock: increment = Mock() # 计数逻辑在识别 mixin 中,按 _recognition 模块命名空间解析 SystemConfigOper monkeypatch.setattr( - "app.chain._recognition.SystemConfigOper", + "app.chain._recognition.get_configured_system_config", lambda: SimpleNamespace(increment=increment), ) return increment diff --git a/tests/test_media_response_models.py b/tests/test_media_response_models.py index c00a068a9..cbef8af03 100644 --- a/tests/test_media_response_models.py +++ b/tests/test_media_response_models.py @@ -127,22 +127,16 @@ async def test_media_response_accepts_legacy_source_key() -> None: @pytest.mark.asyncio -async def test_media_exists_not_found_is_a_successful_query(monkeypatch) -> None: +async def test_media_exists_not_found_is_a_successful_query() -> None: """媒体库未命中是查询结果,不应被统一客户端识别为接口失败。""" - class EmptyMediaServerOper: + class EmptyMediaServerQueryService: """返回未命中的媒体库查询桩。""" - async def async_exists(self, **_kwargs): + async def find_item_id(self, **_kwargs): """模拟媒体库中不存在目标媒体。""" return None - monkeypatch.setattr( - mediaserver_endpoint, - "MediaServerOper", - lambda _db: EmptyMediaServerOper(), - ) - response = await mediaserver_endpoint.exists_local( title="未入库电影", year="2026", @@ -150,7 +144,7 @@ async def test_media_exists_not_found_is_a_successful_query(monkeypatch) -> None media_source=None, media_id=None, season=None, - db=object(), + service=EmptyMediaServerQueryService(), _=None, ) diff --git a/tests/test_media_search_source_selection.py b/tests/test_media_search_source_selection.py index 809fe8582..fbccbb321 100644 --- a/tests/test_media_search_source_selection.py +++ b/tests/test_media_search_source_selection.py @@ -8,7 +8,7 @@ from fastapi import FastAPI from app.api.endpoints import media as media_endpoints from app.api.endpoints.media import search from app.chain import ChainBase -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token from app.modules.douban import DoubanModule from app.modules.themoviedb import TheMovieDbModule from app.schemas.types import MediaSource, MediaType diff --git a/tests/test_mediaserver_conf_sync_interval.py b/tests/test_mediaserver_conf_sync_interval.py index 0f3883545..9785f98c0 100644 --- a/tests/test_mediaserver_conf_sync_interval.py +++ b/tests/test_mediaserver_conf_sync_interval.py @@ -1,4 +1,4 @@ -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.runtime.extensions.service_config import ServiceConfigHelper from app.schemas.system import MediaServerConf from app.schemas.types import SystemConfigKey diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 051e0ac29..f68b08304 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -10,7 +10,7 @@ from app.db import AsyncSessionFactory, SessionFactory from app.db.oper.message import MessageOper from app.db.models.message import Message as MessageModel from app.db.oper.systemconfig import SystemConfigOper -from app.application.messaging.message import MessageHelper +from app.application.messaging.message import MessageHelper, MessageQueryService from app.schemas import Message, MessageClearScope from app.schemas.types import MediaType, MessageType, SystemConfigKey @@ -107,7 +107,9 @@ def test_notification_clear_marker_filters_history_across_requests() -> None: 通过异步接口读取通知标题。 """ async with AsyncSessionFactory() as db: - messages = await get_notification_message(db=db) + messages = await get_notification_message( + service=MessageQueryService(MessageOper(db)) + ) return [message.title for message in messages] assert asyncio.run(_load_titles()) == ["新媒体通知", "旧系统通知"] diff --git a/tests/test_mfa_passkey_transactions.py b/tests/test_mfa_passkey_transactions.py index 157e91578..a7f449e89 100644 --- a/tests/test_mfa_passkey_transactions.py +++ b/tests/test_mfa_passkey_transactions.py @@ -66,15 +66,17 @@ def test_registration_uses_server_challenge(): name="test", ) passkey = Mock() + service = SimpleNamespace(create=Mock(return_value=passkey)) with patch.object( mfa_endpoint.PassKeyHelper, "verify_registration_response", return_value=("credential-id", b"public-key", 0, "aaguid"), - ) as verify, patch.object(mfa_endpoint, "PassKey", return_value=passkey): + ) as verify: result = mfa_endpoint.passkey_register_finish( passkey_req=request, current_user=SimpleNamespace(id=1, name="user"), + service=service, ) assert result.success @@ -82,7 +84,7 @@ def test_registration_uses_server_challenge(): credential=request.credential, expected_challenge="server-challenge", ) - passkey.create.assert_called_once_with() + service.create.assert_called_once() def test_authentication_transaction_rejects_other_user_credential(): @@ -98,18 +100,16 @@ def test_authentication_transaction_rejects_other_user_credential(): passkey = SimpleNamespace(user_id=2) user = SimpleNamespace(id=2, is_active=True) + service = SimpleNamespace(get_by_credential_id=Mock(return_value=passkey)) + lookup = Mock(return_value=user) with patch.object( mfa_endpoint, "_extract_and_standardize_credential_id", return_value="credential-id", ), patch.object( - mfa_endpoint.PassKey, - "get_by_credential_id", - return_value=passkey, - ), patch.object( - mfa_endpoint.User, - "get_by_id", - return_value=user, + mfa_endpoint, + "get_configured_user_name_lookup", + return_value=lookup, ), patch.object( mfa_endpoint, "_verify_passkey_and_update", @@ -119,6 +119,7 @@ def test_authentication_transaction_rejects_other_user_credential(): request=_request(), response=Response(), passkey_req=request, + service=service, ) assert exc_info.value.status_code == 401 @@ -145,48 +146,41 @@ def test_authentication_finish_token_cannot_be_replayed(): permissions={}, ) + service = SimpleNamespace(get_by_credential_id=Mock(return_value=passkey)) + lookup = Mock(return_value=user) + token_response = SimpleNamespace(access_token="access-token", level=1) with patch.object( mfa_endpoint, "_extract_and_standardize_credential_id", return_value="credential-id", ), patch.object( - mfa_endpoint.PassKey, - "get_by_credential_id", - return_value=passkey, - ), patch.object( - mfa_endpoint.User, - "get_by_id", - return_value=user, + mfa_endpoint, + "get_configured_user_id_lookup", + return_value=lookup, ), patch.object( mfa_endpoint, "_verify_passkey_and_update", return_value=(True, 0), ), patch.object( mfa_endpoint, - "SitesHelper", - return_value=SimpleNamespace(auth_level=1), + "get_configured_auth_service", + return_value=SimpleNamespace(build_token_response=Mock(return_value=token_response)), ), patch.object( mfa_endpoint, - "SystemConfigOper", - return_value=SimpleNamespace(get=lambda _: True), - ), patch.object( - mfa_endpoint.security, - "create_access_token", - return_value="access-token", - ), patch.object( - mfa_endpoint.security, "set_or_refresh_resource_token_cookie", ): result = mfa_endpoint.passkey_authenticate_finish( request=_request(), response=Response(), passkey_req=request, + service=service, ) with pytest.raises(HTTPException) as replay_error: mfa_endpoint.passkey_authenticate_finish( request=_request(), response=Response(), passkey_req=request, + service=service, ) assert result.access_token == "access-token" diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index 3cdad62b5..5cc183eac 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -21,6 +21,8 @@ from app.runtime.capabilities.registry import CapabilityRegistry from app.runtime.events import Event, EventHandlerBinding, eventmanager from app.runtime.extensions import module_manager as module_manager_extension from app.runtime.extensions.module_manager import ModuleManager + +from app.runtime.extensions.service_config import configure_service_config_reader from app.schemas import ConfigChangeEventData from app.schemas.types import EventType @@ -249,6 +251,9 @@ def module_manager_harness( return config_values.get(key_value) monkeypatch.setattr(SystemConfigOper, "get", get_config) + previous_config_reader = configure_service_config_reader( + lambda key: SystemConfigOper().get(key) + ) singleton_key = (ModuleManager, (), frozenset()) previous_manager = Singleton._instances.pop(singleton_key, None) @@ -283,6 +288,7 @@ def module_manager_harness( subscribers.pop(EventType.ConfigChanged, None) for module_name in ("fixture_sample_module", "fixture_other_module"): sys.modules.pop(module_name, None) + configure_service_config_reader(previous_config_reader) restored = True try: @@ -789,6 +795,9 @@ settings.ACOUSTID_API_KEY = None settings.FANART_API_KEY = None from app.runtime.extensions.module_manager import ModuleManager +from app.application.module import configure_module_runtime + +configure_module_runtime(lambda: ModuleManager()) manager = ModuleManager() assert len(manager.list_specs()) == 37 diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index 5f09c8d64..f7d08eca7 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -853,7 +853,7 @@ def test_follow_preserves_album_entity_and_track_count(): system_config.get.return_value = ["follow-user"] with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \ - patch("app.chain.subscribe.SystemConfigOper", return_value=system_config), \ + patch("app.chain.subscribe.get_configured_system_config", return_value=system_config), \ patch( "app.chain.subscribe.MoviePilotServerHelper.get_subscribe_shares", return_value=[share], @@ -879,7 +879,7 @@ def test_refresh_enables_music_entry_fetch_when_music_subscribe_exists(): torrents_chain.refresh.return_value = {} with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \ - patch("app.chain.subscribe.SystemConfigOper") as system_config, \ + patch("app.chain.subscribe.get_configured_system_config") as system_config, \ patch("app.chain.subscribe.TorrentsChain", return_value=torrents_chain): system_config.return_value.get.return_value = [] chain.refresh() diff --git a/tests/test_password_hashing.py b/tests/test_password_hashing.py index 575d74d86..a0cc8edf5 100644 --- a/tests/test_password_hashing.py +++ b/tests/test_password_hashing.py @@ -5,7 +5,7 @@ import bcrypt import pytest from app.api.endpoints import user as user_endpoint -from app.application.security.access import ( +from app.application.security.token import ( PasswordTooLongError, get_password_hash, verify_password, @@ -85,7 +85,7 @@ class _CurrentUser: """提供用户接口长度校验前需要的最小查询契约。""" @staticmethod - async def async_get_by_name(_db, name): + async def async_get_by_name(name): """模拟用户名尚未被使用。""" assert name == "new-user" return None @@ -95,7 +95,7 @@ def test_create_user_returns_business_error_for_password_over_72_bytes(): """新增用户遇到超长密码时应返回可读业务错误。""" response = asyncio.run( user_endpoint.create_user( - db=SimpleNamespace(), + service=SimpleNamespace(get_by_name=_CurrentUser.async_get_by_name), user_in=_CreateUserInput(), current_user=_CurrentUser(), ) @@ -117,7 +117,7 @@ def test_update_user_returns_business_error_for_password_over_72_bytes(): response = asyncio.run( user_endpoint.update_user( - db=SimpleNamespace(), + service=SimpleNamespace(), user_in=user_in, current_user=SimpleNamespace(), ) diff --git a/tests/test_plugin_dashboard.py b/tests/test_plugin_dashboard.py index 716be05b8..ccca0e675 100644 --- a/tests/test_plugin_dashboard.py +++ b/tests/test_plugin_dashboard.py @@ -2,9 +2,8 @@ from types import SimpleNamespace from typing import Any, Iterator import pytest -from fastapi import HTTPException - from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.extensions.plugin.contracts import PluginDashboardError from app.foundation.singleton import Singleton @@ -58,8 +57,7 @@ def test_plugin_dashboard_rejects_invalid_dashboard_shape(plugin_manager: Plugin {"cols": {}, "attrs": {}, "elements": []} ) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(PluginDashboardError) as exc_info: plugin_manager.get_plugin_dashboard("DemoPlugin", "broken") - assert exc_info.value.status_code == 500 - assert "仪表盘数据格式错误" in exc_info.value.detail + assert "仪表盘数据格式错误" in str(exc_info.value) diff --git a/tests/test_plugin_rating.py b/tests/test_plugin_rating.py index 902c1d6cb..6b60538cd 100644 --- a/tests/test_plugin_rating.py +++ b/tests/test_plugin_rating.py @@ -80,7 +80,7 @@ def test_plugin_rating_endpoints_return_center_results() -> None: system_config = MagicMock() system_config.get.return_value = ["DemoPlugin"] with ( - patch("app.api.endpoints.plugin.SystemConfigOper", return_value=system_config), + patch("app.api.endpoints.plugin.get_configured_system_config", return_value=system_config), patch.object( MoviePilotServerHelper, "async_submit_plugin_rating", @@ -107,7 +107,7 @@ def test_plugin_rating_rejects_uninstalled_plugin() -> None: system_config = MagicMock() system_config.get.return_value = [] with ( - patch("app.api.endpoints.plugin.SystemConfigOper", return_value=system_config), + patch("app.api.endpoints.plugin.get_configured_system_config", return_value=system_config), patch.object( MoviePilotServerHelper, "async_submit_plugin_rating", diff --git a/tests/test_resource_token_cookie_secure_flag.py b/tests/test_resource_token_cookie_secure_flag.py index 4ee25cc84..cced75a70 100644 --- a/tests/test_resource_token_cookie_secure_flag.py +++ b/tests/test_resource_token_cookie_secure_flag.py @@ -6,7 +6,8 @@ from fastapi import Response from app import schemas from app.runtime.config import settings -from app.application.security.access import ALGORITHM, create_access_token, set_or_refresh_resource_token_cookie +from app.adapters.web.security.access import set_or_refresh_resource_token_cookie +from app.application.security.token import ALGORITHM, create_access_token class FakeURL: diff --git a/tests/test_search_ai_recommend.py b/tests/test_search_ai_recommend.py index be2739fe3..a5d3d7f74 100644 --- a/tests/test_search_ai_recommend.py +++ b/tests/test_search_ai_recommend.py @@ -189,7 +189,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): with ( patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True), - patch("app.chain.search.SystemConfigOper") as system_config_oper, + patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, patch("app.chain.search.ProgressHelper") as progress_helper, ): @@ -235,7 +235,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): with ( patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True), - patch("app.chain.search.SystemConfigOper") as system_config_oper, + patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, patch("app.chain.search.ProgressHelper") as progress_helper, ): @@ -281,7 +281,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): with ( patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True), - patch("app.chain.search.SystemConfigOper") as system_config_oper, + patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, patch("app.chain.search.ProgressHelper") as progress_helper, ): @@ -346,7 +346,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): with ( patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True), - patch("app.chain.search.SystemConfigOper") as system_config_oper, + patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, patch("app.chain.search.ProgressHelper") as progress_helper, ): @@ -392,7 +392,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): with ( patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True), - patch("app.chain.search.SystemConfigOper") as system_config_oper, + patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, patch("app.chain.search.ProgressHelper") as progress_helper, ): @@ -473,7 +473,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): def test_tool_factory_excludes_message_tools_when_disabled(self): with patch( - "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + "app.agent.tools.factory._get_plugin_agent_tools", return_value=[], ): tools = MoviePilotToolFactory.create_tools( diff --git a/tests/test_search_title_filter.py b/tests/test_search_title_filter.py index 6851cc269..6b934d0dd 100644 --- a/tests/test_search_title_filter.py +++ b/tests/test_search_title_filter.py @@ -34,7 +34,7 @@ def _patch_search_filter_rule_groups(monkeypatch, rule_groups: list[str]) -> Non oper = SimpleNamespace( get=lambda key: rule_groups if key == SystemConfigKey.SearchFilterRuleGroups else None ) - monkeypatch.setattr(search_module, "SystemConfigOper", lambda: oper) + monkeypatch.setattr(search_module, "get_configured_system_config", lambda: oper) def test_search_by_title_applies_default_search_filter_rule_groups(monkeypatch): diff --git a/tests/test_servarr_series_add.py b/tests/test_servarr_series_add.py index 234a0f52f..2432c8387 100644 --- a/tests/test_servarr_series_add.py +++ b/tests/test_servarr_series_add.py @@ -44,9 +44,15 @@ def _series(tmdb_id=None, seasons=None): ) -def _run_add(tv): +def _run_add(tv, subscriptions): """直接调用新增剧集订阅处理函数。""" - return asyncio.run(arr_add_series(tv=tv, _="api-token", db=object())) + return asyncio.run( + arr_add_series( + tv=tv, + _="api-token", + subscriptions=subscriptions, + ) + ) def _patch_chains(mediainfo=None, exists=None, add_result=(123, "")): @@ -60,26 +66,25 @@ def _patch_chains(mediainfo=None, exists=None, add_result=(123, "")): media_chain.recognize_by_meta.return_value = mediainfo subscribe_chain = MagicMock() subscribe_chain.async_add = AsyncMock(return_value=add_result) + subscriptions = MagicMock() + subscriptions.exists = AsyncMock(return_value=bool(exists)) return patch( "app.api.servarr.MediaChain", return_value=media_chain, ), patch( "app.api.servarr.SubscribeChain", return_value=subscribe_chain, - ), patch( - "app.api.servarr.Subscribe.async_exists", - new=AsyncMock(return_value=exists), - ), subscribe_chain + ), subscriptions, subscribe_chain def test_add_series_without_tmdbid_resolves_identity_via_tvdbid(): """Seerr 请求体不携带 tmdbId 时,应按 tvdbId 补全媒体身份并创建订阅。""" tv = _series(seasons=[SonarrSeason(seasonNumber=1, monitored=True)]) - media_patch, chain_patch, exists_patch, subscribe_chain = _patch_chains( + media_patch, chain_patch, subscriptions, subscribe_chain = _patch_chains( mediainfo=_fake_mediainfo() ) - with media_patch, chain_patch, exists_patch: - result = _run_add(tv) + with media_patch, chain_patch: + result = _run_add(tv, subscriptions) assert result.id == 123 subscribe_chain.async_add.assert_awaited_once_with( @@ -96,12 +101,12 @@ def test_add_series_without_tmdbid_resolves_identity_via_tvdbid(): def test_add_series_with_empty_seasons_falls_back_to_all_seasons(): """请求体季列表为空时不应静默成功,应兜底订阅已识别的全部季。""" tv = _series() - media_patch, chain_patch, exists_patch, subscribe_chain = _patch_chains( + media_patch, chain_patch, subscriptions, subscribe_chain = _patch_chains( mediainfo=_fake_mediainfo(seasons={1: [1, 2, 3], 2: [1]}) ) subscribe_chain.async_add = AsyncMock(side_effect=[(100, ""), (101, "")]) - with media_patch, chain_patch, exists_patch: - result = _run_add(tv) + with media_patch, chain_patch: + result = _run_add(tv, subscriptions) assert result.id == 101 assert subscribe_chain.async_add.await_count == 2 @@ -115,11 +120,11 @@ def test_add_series_already_subscribed_returns_existing(): tmdb_id=_TMDB_ID, seasons=[SonarrSeason(seasonNumber=1, monitored=True)], ) - media_patch, chain_patch, exists_patch, subscribe_chain = _patch_chains( + media_patch, chain_patch, subscriptions, subscribe_chain = _patch_chains( exists=SimpleNamespace(id=9) ) - with media_patch, chain_patch, exists_patch: - result = _run_add(tv) + with media_patch, chain_patch: + result = _run_add(tv, subscriptions) assert result.id == 1 subscribe_chain.async_add.assert_not_awaited() @@ -128,10 +133,10 @@ def test_add_series_already_subscribed_returns_existing(): def test_add_series_identity_resolution_failure_returns_500(): """媒体身份补全失败时返回 500,避免 Seerr 误判请求已成功。""" tv = _series() - media_patch, chain_patch, exists_patch, _ = _patch_chains(mediainfo=None) - with media_patch, chain_patch, exists_patch: + media_patch, chain_patch, subscriptions, _ = _patch_chains(mediainfo=None) + with media_patch, chain_patch: with pytest.raises(HTTPException) as excinfo: - _run_add(tv) + _run_add(tv, subscriptions) assert excinfo.value.status_code == 500 @@ -148,18 +153,21 @@ def test_series_lookup_falls_back_to_tmdb_seasons(): seasons={1: [1, 2, 3], 2: [1]} ) media_chain.media_exists.return_value = False + subscriptions = MagicMock() + subscriptions.list_by_media_identity_sync.return_value = [] with patch( "app.api.servarr.TvdbChain", return_value=MagicMock(get_tvdbid_by_name=MagicMock(return_value=[_TVDB_ID])), ), patch( "app.api.servarr.MediaChain", return_value=media_chain, - ), patch( - "app.api.servarr.Subscribe.list_by_media_identity", - return_value=[], ): - result = arr_series_lookup(term=f"tvdb:{_TVDB_ID}", _="api-token", db=object()) + result = arr_series_lookup( + term=f"tvdb:{_TVDB_ID}", + _="api-token", + subscriptions=subscriptions, + ) assert len(result) == 1 assert [season.seasonNumber for season in result[0].seasons] == [1, 2] - assert all(not season.monitored for season in result[0].seasons) \ No newline at end of file + assert all(not season.monitored for season in result[0].seasons) diff --git a/tests/test_site_cookie_endpoint.py b/tests/test_site_cookie_endpoint.py index 225a59a3d..84bc3fcee 100644 --- a/tests/test_site_cookie_endpoint.py +++ b/tests/test_site_cookie_endpoint.py @@ -14,13 +14,11 @@ def test_update_cookie_by_body_uses_request_body(): fake_chain.update_cookie.return_value = (True, "ok") request = schemas.SiteCookieUpdate(username="user", password="password", code="123456") - with patch.object(site_endpoint.Site, "get", return_value=fake_site), patch.object( - site_endpoint, "SiteChain", return_value=fake_chain - ): + with patch.object(site_endpoint, "SiteChain", return_value=fake_chain): response = site_endpoint.update_cookie_by_body( site_id=1, site_cookie_update=request, - db=Mock(), + query=SimpleNamespace(get_sync=lambda _site_id: fake_site), _=Mock(), ) @@ -42,15 +40,13 @@ def test_update_cookie_legacy_get_keeps_query_params(): fake_chain = Mock() fake_chain.update_cookie.return_value = (False, "failed") - with patch.object(site_endpoint.Site, "get", return_value=fake_site), patch.object( - site_endpoint, "SiteChain", return_value=fake_chain - ): + with patch.object(site_endpoint, "SiteChain", return_value=fake_chain): response = site_endpoint.update_cookie( site_id=1, username="user", password="password", code=None, - db=Mock(), + query=SimpleNamespace(get_sync=lambda _site_id: fake_site), _=Mock(), ) diff --git a/tests/test_site_media_filter.py b/tests/test_site_media_filter.py index 3a90ae5aa..b386bb5b3 100644 --- a/tests/test_site_media_filter.py +++ b/tests/test_site_media_filter.py @@ -52,14 +52,18 @@ def test_read_sites_by_media_type_filters_configured_active_sites(monkeypatch, m ] list_sites = AsyncMock(return_value=sites) get_indexers = AsyncMock(return_value=indexers) - monkeypatch.setattr(site_endpoint.Site, "async_list_order_by_pri", list_sites) monkeypatch.setattr( site_endpoint, "SitesHelper", lambda: SimpleNamespace(async_get_indexers=get_indexers), ) - result = asyncio.run(site_endpoint.read_sites_by_media_type(media_type, db=AsyncMock())) + result = asyncio.run( + site_endpoint.read_sites_by_media_type( + media_type, + query=SimpleNamespace(list_ordered=list_sites), + ) + ) assert [site.id for site in result] == expected_ids list_sites.assert_awaited_once() @@ -69,7 +73,12 @@ def test_read_sites_by_media_type_filters_configured_active_sites(monkeypatch, m def test_read_sites_by_media_type_rejects_unknown_type(): """未知媒体类型应返回明确的客户端参数错误。""" with pytest.raises(HTTPException) as error: - asyncio.run(site_endpoint.read_sites_by_media_type("podcast", db=AsyncMock())) + asyncio.run( + site_endpoint.read_sites_by_media_type( + "podcast", + query=SimpleNamespace(list_ordered=AsyncMock()), + ) + ) assert error.value.status_code == 400 assert error.value.detail == "不支持的媒体类型" diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 2227aad2a..e463210db 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -7,10 +7,31 @@ import pytest from pydantic import ValidationError from app.api.endpoints.subscribe import create_subscribe +from app.application.subscription.mutation import SubscriptionMutationService +from app.application.subscription.query import SubscriptionQueryService +from app.db.oper.subscribe import SubscribeOper +from app.db.oper.subscribehistory import SubscribeHistoryOper from app.schemas.subscribe import Subscribe from app.schemas.types import EventType, MediaSource, MediaType +def _subscription_query(db: object = None) -> SubscriptionQueryService: + """构造使用测试数据库对象的订阅查询服务。""" + return SubscriptionQueryService( + repository=SubscribeOper(db), + async_repository=SubscribeOper(db), + history_repository=SubscribeHistoryOper(db), + ) + + +def _subscription_mutation(db: object = None) -> SubscriptionMutationService: + """构造使用测试数据库对象的订阅写服务。""" + return SubscriptionMutationService( + repository=SubscribeOper(db), + history_repository=SubscribeHistoryOper(db), + ) + + class SubscribeEndpointTest(TestCase): """ 订阅接口回归测试。 @@ -28,18 +49,20 @@ class SubscribeEndpointTest(TestCase): all_subscribes = [own, other, legacy] with patch( - "app.api.endpoints.subscribe.Subscribe.async_list", + "app.db.oper.subscribe.Subscribe.async_list", new=AsyncMock(return_value=all_subscribes), ), patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_username", + "app.db.oper.subscribe.Subscribe.async_list_by_username", new=AsyncMock(return_value=[own]), ): - api_token_result = asyncio.run(list_subscribes(_="api-token")) + api_token_result = asyncio.run( + list_subscribes(query=_subscription_query(object()), _="api-token") + ) self.assertEqual([sub.id for sub in api_token_result], [1, 2, 3]) regular_result = asyncio.run( read_subscribes( - db=object(), + query=_subscription_query(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -47,7 +70,7 @@ class SubscribeEndpointTest(TestCase): superuser_result = asyncio.run( read_subscribes( - db=object(), + query=_subscription_query(object()), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) @@ -68,13 +91,13 @@ class SubscribeEndpointTest(TestCase): for subscribe, expected_id in cases: with self.subTest(subscribe_id=subscribe.id), patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.Subscribe.async_get", new=AsyncMock(return_value=subscribe), ): result = asyncio.run( read_subscribe( subscribe_id=subscribe.id, - db=object(), + query=_subscription_query(object()), current_user=current_user, ) ) @@ -130,7 +153,7 @@ class SubscribeEndpointTest(TestCase): ), ]: with self.subTest(subscribe_id=subscribe.id), patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(return_value=subscribe), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -144,7 +167,7 @@ class SubscribeEndpointTest(TestCase): total_episode=8, lack_episode=2, ), - db=object(), + mutation=_subscription_mutation(object()), current_user=manage_user, ) ) @@ -173,7 +196,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -187,7 +210,7 @@ class SubscribeEndpointTest(TestCase): total_episode=8, lack_episode=2, ), - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -222,7 +245,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -231,7 +254,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( update_subscribe( subscribe_in=subscribe_in, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -262,7 +285,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -271,7 +294,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( update_subscribe( subscribe_in=Subscribe(id=24, name="新标题"), - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -299,7 +322,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -313,7 +336,7 @@ class SubscribeEndpointTest(TestCase): media_source="", media_id="", ), - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -355,7 +378,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -364,7 +387,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( update_subscribe( subscribe_in=subscribe_in, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -386,7 +409,7 @@ class SubscribeEndpointTest(TestCase): _EndpointSubscribe(id=6, username=None, state="R", name="旧订阅"), ]: with self.subTest(subscribe_id=subscribe.id), patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -396,7 +419,7 @@ class SubscribeEndpointTest(TestCase): update_subscribe_status( subid=subscribe.id, state="S", - db=object(), + mutation=_subscription_mutation(object()), current_user=current_user, ) ) @@ -415,7 +438,7 @@ class SubscribeEndpointTest(TestCase): other = _EndpointSubscribe(id=7, username="bob", name="他人的订阅") with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(return_value=other), ), patch( "app.api.endpoints.subscribe.MoviePilotServerHelper.async_sub_share", @@ -429,7 +452,7 @@ class SubscribeEndpointTest(TestCase): share_comment="", share_user="alice", ), - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -452,7 +475,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity", + "app.db.oper.subscribe.SubscribeOper.async_list_by_media_identity", new=AsyncMock(return_value=[other, own]), ): result = asyncio.run( @@ -460,7 +483,7 @@ class SubscribeEndpointTest(TestCase): media_id="123", media_source=MediaSource.TMDB, season=1, - db=object(), + query=_subscription_query(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -489,7 +512,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity", + "app.db.oper.subscribe.SubscribeOper.async_list_by_media_identity", new=AsyncMock(return_value=[recording, album]), ) as list_by_identity: result = asyncio.run( @@ -497,7 +520,7 @@ class SubscribeEndpointTest(TestCase): media_id="shared-id", media_source=MediaSource.MusicBrainz, music_type="album", - db=object(), + query=_subscription_query(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -510,10 +533,10 @@ class SubscribeEndpointTest(TestCase): from app.api.endpoints.subscribe import subscribe_media_identity with patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity", + "app.db.oper.subscribe.SubscribeOper.async_list_by_media_identity", new=AsyncMock(return_value=[]), ), patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_title", + "app.db.oper.subscribe.SubscribeOper.async_list_by_title", new=AsyncMock(), ) as title_lookup: result = asyncio.run( @@ -522,7 +545,7 @@ class SubscribeEndpointTest(TestCase): media_source=MediaSource.MusicBrainz, title="周杰伦 - 晴天", music_type="recording", - db=object(), + query=_subscription_query(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -612,14 +635,14 @@ class SubscribeEndpointTest(TestCase): other = _EndpointSubscribe(id=19, username="bob", name="他人的订阅") with patch( - "app.api.endpoints.subscribe.Subscribe.get", + "app.db.oper.subscribe.SubscribeOper.get", return_value=other, ), patch( "app.api.endpoints.subscribe.SubscribeChain" ) as subscribe_chain: result = subscribe_files( subscribe_id=19, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) @@ -633,13 +656,13 @@ class SubscribeEndpointTest(TestCase): from app.api.endpoints.subscribe import user_subscribes with patch( - "app.api.endpoints.subscribe.Subscribe.async_list_by_username", + "app.db.oper.subscribe.SubscribeOper.async_list_by_username", new=AsyncMock(return_value=[_EndpointSubscribe(id=20, username="bob")]), ) as list_by_username: result = asyncio.run( user_subscribes( username="bob", - db=object(), + query=_subscription_query(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -710,10 +733,10 @@ class SubscribeEndpointTest(TestCase): global_query = AsyncMock(return_value=[other, legacy]) with patch( - "app.api.endpoints.subscribe.SubscribeHistory.async_list_by_type", + "app.db.oper.subscribehistory.SubscribeHistoryOper.async_list_by_type", new=global_query, ), patch( - "app.api.endpoints.subscribe.SubscribeHistory.async_list_by_type_and_username", + "app.db.oper.subscribehistory.SubscribeHistoryOper.async_list_by_type_and_username", new=owner_query, create=True, ): @@ -722,17 +745,16 @@ class SubscribeEndpointTest(TestCase): mtype=MediaType.MOVIE.value, page=1, count=2, - db=db, + query=_subscription_query(db), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) self.assertEqual([history.id for history in regular_result], [8]) owner_query.assert_awaited_once_with( - db, - mtype=MediaType.MOVIE.value, - username="alice", - page=1, - count=2, + MediaType.MOVIE.value, + "alice", + 1, + 2, ) global_query.assert_not_awaited() @@ -745,16 +767,15 @@ class SubscribeEndpointTest(TestCase): mtype=MediaType.MOVIE.value, page=1, count=3, - db=db, + query=_subscription_query(db), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) self.assertEqual([history.id for history in superuser_result], [8, 9, 10]) global_query.assert_awaited_once_with( - db, - mtype=MediaType.MOVIE.value, - page=1, - count=3, + MediaType.MOVIE.value, + 1, + 3, ) owner_query.assert_not_awaited() @@ -772,16 +793,16 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.SubscribeHistory.async_get", + "app.db.oper.subscribehistory.SubscribeHistoryOper.async_get", new=AsyncMock(return_value=other), ), patch( - "app.api.endpoints.subscribe.SubscribeHistory.async_delete", + "app.db.oper.subscribehistory.SubscribeHistoryOper.async_delete", new=AsyncMock(), ) as async_delete: response = asyncio.run( delete_subscribe_history( history_id=11, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="alice", is_superuser=False), ) ) @@ -970,7 +991,7 @@ class SubscribeEndpointTest(TestCase): subscribe = _EndpointSubscribe(id=5, state="R", name="测试订阅") with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -980,7 +1001,7 @@ class SubscribeEndpointTest(TestCase): update_subscribe_status( subid=5, state="S", - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) @@ -1014,7 +1035,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -1023,7 +1044,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( reset_subscribes( subid=6, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) @@ -1069,7 +1090,7 @@ class SubscribeEndpointTest(TestCase): subscribe_in = Subscribe(id=7, name="新标题", total_episode=8, lack_episode=2) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -1078,7 +1099,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( update_subscribe( subscribe_in=subscribe_in, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) @@ -1130,7 +1151,7 @@ class SubscribeEndpointTest(TestCase): ) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -1139,7 +1160,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( update_subscribe( subscribe_in=subscribe_in, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) @@ -1175,7 +1196,7 @@ class SubscribeEndpointTest(TestCase): subscribe_in = Subscribe(id=9, name="测试剧集", total_episode=12, lack_episode=0) with patch( - "app.api.endpoints.subscribe.Subscribe.async_get", + "app.db.oper.subscribe.SubscribeOper.async_get", new=AsyncMock(side_effect=[subscribe, subscribe]), ), patch( "app.api.endpoints.subscribe.eventmanager.async_send_event", @@ -1184,7 +1205,7 @@ class SubscribeEndpointTest(TestCase): response = asyncio.run( update_subscribe( subscribe_in=subscribe_in, - db=object(), + mutation=_subscription_mutation(object()), current_user=_EndpointUser(name="admin", is_superuser=True), ) ) diff --git a/tests/test_subtitle_signed_download.py b/tests/test_subtitle_signed_download.py index 6737f2fcc..68ae2c9f2 100644 --- a/tests/test_subtitle_signed_download.py +++ b/tests/test_subtitle_signed_download.py @@ -232,17 +232,24 @@ def test_download_subtitle_cleans_url_and_uses_server_site_request_fields(monkey captured = {} signed_url = SecurityUtils.sign_url(SUBTITLE_URL, purpose=SUBTITLE_PURPOSE) - class FakeSiteOper: + class FakeSiteQuery: def get(self, site_id): assert site_id == SUBTITLE_SITE_ID return SimpleNamespace(cookie="server-cookie=1", ua="ServerUA", proxy=True) + def get_sync(self, site_id): + return self.get(site_id) + class FakeDownloadChain: def download_subtitle(self, **kwargs): captured.update(kwargs) return True, "字幕下载成功", ["/downloads/Demo.Movie.2026.zh-cn.srt"] - monkeypatch.setattr(download_endpoint, "SiteOper", FakeSiteOper, raising=False) + monkeypatch.setattr( + download_endpoint, + "get_configured_site_query_service", + lambda: FakeSiteQuery(), + ) monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain) response = download_endpoint.download_subtitle( diff --git a/tests/test_system_nettest.py b/tests/test_system_nettest.py index 029bc2b6d..10ac9a46c 100644 --- a/tests/test_system_nettest.py +++ b/tests/test_system_nettest.py @@ -42,7 +42,7 @@ _STUB_MODULES = dict([ _stub("app.runtime.events", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy), _stub("app.domain.metainfo", MetaInfo=_Dummy), _stub("app.runtime.extensions.module_manager", ModuleManager=_Dummy), - _stub("app.application.security.access", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy), + _stub("app.adapters.web.security.access", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy), _stub("app.db.models", User=_Dummy), _stub("app.db.oper.systemconfig", SystemConfigOper=_Dummy), _stub("app.api.deps", get_current_active_superuser=_Dummy, diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index 22afcceba..726472cb2 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -104,7 +104,7 @@ class SystemHelperRestartTest(TestCase): settings.TEMP_PATH / "moviepilot.intentional_restart" ) try: - with patch("app.runtime.state.SystemUtils.is_docker", return_value=True), \ + with patch("app.runtime.state.is_docker", return_value=True), \ patch.object(SystemHelper, "_check_restart_policy", return_value=True), \ patch.object(SystemHelper, "_start_graceful_shutdown_monitor"), \ patch("app.runtime.state.os.kill") as kill_mock: diff --git a/tests/test_tmdb_cache_management.py b/tests/test_tmdb_cache_management.py index 7da6b9cd9..16abe0741 100644 --- a/tests/test_tmdb_cache_management.py +++ b/tests/test_tmdb_cache_management.py @@ -279,7 +279,7 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch): ) monkeypatch.setattr( tmdb_endpoint, - "SystemConfigOper", + "get_configured_system_config", lambda: type("SystemConfigStub", (), {"get": get_system_config})(), ) monkeypatch.setattr(tmdb_endpoint.settings, "MEDIA_RECOGNIZE_SHARE", True) diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index 24d56c697..447d2bd57 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -42,7 +42,7 @@ def test_manual_music_transfer_forwards_entity_namespace(monkeypatch): music_type="album", ), background=True, - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -73,22 +73,17 @@ def test_manual_transfer_from_history_preserves_download_context(monkeypatch): captured = {} - def fake_get(_db, logid): - assert logid == 1 - return history - class FakeTransferChain: def manual_transfer(self, **kwargs): captured.update(kwargs) return True, "" - monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) resp = manual_transfer( transer_item=ManualTransferItem(logid=1, from_history=True), background=True, - db=object(), + history_query=SimpleNamespace(get=lambda history_id: history if history_id == 1 else None), _="token", ) @@ -122,11 +117,6 @@ def test_manual_transfer_without_history_recognition_ignores_old_hash(monkeypatc ) captured = {} - def fake_get(_db, logid): - """返回受污染的历史记录。""" - assert logid == 1 - return history - class FakeTransferChain: """记录 API 传入整理链的参数。""" @@ -135,13 +125,12 @@ def test_manual_transfer_without_history_recognition_ignores_old_hash(monkeypatc captured.update(kwargs) return True, "" - monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) resp = manual_transfer( transer_item=ManualTransferItem(logid=1, from_history=False), background=True, - db=object(), + history_query=SimpleNamespace(get=lambda history_id: history if history_id == 1 else None), _="token", ) @@ -180,22 +169,17 @@ def test_manual_transfer_from_history_passes_old_dest_cleanup_to_chain(monkeypat ) captured = {} - def fake_get(_db, logid): - assert logid == 1 - return history - class FakeTransferChain: def manual_transfer(self, **kwargs): captured.update(kwargs) return True, "" - monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) resp = manual_transfer( transer_item=ManualTransferItem(logid=1), background=False, - db=object(), + history_query=SimpleNamespace(get=lambda history_id: history if history_id == 1 else None), _="token", ) @@ -231,10 +215,6 @@ def test_manual_transfer_from_history_preview_does_not_cleanup_old_dest(monkeypa ) captured = {} - def fake_get(_db, logid): - assert logid == 1 - return history - class FakeTransferChain: def manual_transfer(self, **kwargs): captured.update(kwargs) @@ -244,13 +224,12 @@ def test_manual_transfer_from_history_preview_does_not_cleanup_old_dest(monkeypa "message": "", } - monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) resp = manual_transfer( transer_item=ManualTransferItem(logid=1, preview=True), background=False, - db=object(), + history_query=SimpleNamespace(get=lambda history_id: history if history_id == 1 else None), _="token", ) @@ -314,7 +293,7 @@ def test_manual_transfer_preview_uses_explicit_fileitems_instead_of_directory(mo preview=True, ), background=False, - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -374,7 +353,7 @@ def test_manual_transfer_preview_multi_select_collects_failures(monkeypatch): preview=True, ), background=False, - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -410,7 +389,7 @@ def test_match_manual_transfer_target_path_returns_directory_match(monkeypatch): "type": "file", }, ), - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -457,7 +436,7 @@ def test_match_manual_transfer_target_path_returns_null_for_ambiguous_matches(mo }, ], ), - db=object(), + history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) @@ -490,9 +469,6 @@ def test_match_manual_transfer_target_path_accepts_multiple_history_records(monk ), } - def fake_get(_db, logid): - return histories.get(logid) - class FakeDirectoryHelper: def get_dir(self, **_kwargs): return TransferDirectoryConf( @@ -501,12 +477,11 @@ def test_match_manual_transfer_target_path_accepts_multiple_history_records(monk transfer_type="copy", ) - monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) monkeypatch.setattr("app.api.endpoints.transfer.DirectoryHelper", FakeDirectoryHelper) resp = match_manual_transfer_target_path( transer_item=ManualTransferItem(logids=[1, 2]), - db=object(), + history_query=SimpleNamespace(get=histories.get), _="token", ) diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 7388aa136..c82a71bf1 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -474,7 +474,7 @@ class TransferJobManagerTest(unittest.TestCase): with patch("app.chain.transfer.TransferHistoryOper", return_value=transfer_history_oper), \ patch("app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper), \ - patch("app.chain.transfer.SystemConfigOper", return_value=system_config_oper), \ + patch("app.chain.transfer.get_configured_system_config", return_value=system_config_oper), \ patch("app.chain.transfer.MetaInfoPath", lambda *args, **kwargs: FakeMeta(14)): state, errmsg = chain.do_transfer( fileitem=source_fileitem, @@ -650,7 +650,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.TransferHistoryOper", return_value=transfer_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ): state, errmsg = TransferChain.do_transfer( @@ -721,7 +721,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ): state, errmsg = TransferChain.do_transfer( @@ -800,7 +800,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ): state, errmsg = TransferChain.do_transfer( @@ -1087,7 +1087,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ), patch( "app.chain.transfer.StorageChain", @@ -1186,7 +1186,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ), patch( "app.chain.transfer.StorageChain", @@ -1267,7 +1267,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ), patch( "app.chain.transfer.StorageChain", @@ -1372,7 +1372,7 @@ class TransferJobManagerTest(unittest.TestCase): "app.chain.transfer.DownloadHistoryOper", return_value=download_history_oper, ), patch( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", return_value=system_config_oper, ), patch( "app.chain.transfer.StorageChain", diff --git a/tests/test_transfer_movie_collection.py b/tests/test_transfer_movie_collection.py index b420cbfdc..f4adcaf16 100644 --- a/tests/test_transfer_movie_collection.py +++ b/tests/test_transfer_movie_collection.py @@ -194,10 +194,10 @@ def test_movie_collection_conflict_only_drops_automatic_media( monkeypatch.setattr("app.chain.transfer.DownloadHistoryOper", lambda: history_oper) monkeypatch.setattr("app.chain._transfer.DownloadHistoryOper", lambda: history_oper) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.StorageChain", lambda: SimpleNamespace()) monkeypatch.setattr("app.chain._transfer.StorageChain", lambda: SimpleNamespace()) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda *args, **kwargs: file_meta) diff --git a/tests/test_transfer_sync_extra_files.py b/tests/test_transfer_sync_extra_files.py index 7e33d8269..5c48f58da 100644 --- a/tests/test_transfer_sync_extra_files.py +++ b/tests/test_transfer_sync_extra_files.py @@ -159,10 +159,10 @@ def test_sync_extra_subtitle_inherits_matching_video_episode(monkeypatch): get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", fake_meta_info_path) state, errmsg = TransferChain.do_transfer( @@ -256,10 +256,10 @@ def test_single_subtitle_transfer_reuses_same_name_video_episode(monkeypatch): get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -371,10 +371,10 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -469,10 +469,10 @@ def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch): get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1)) state, errmsg = TransferChain.do_transfer( @@ -556,10 +556,10 @@ def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch): get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1)) state, errmsg = TransferChain.do_transfer( @@ -652,10 +652,10 @@ def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypat get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -748,10 +748,10 @@ def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeyp get_by_path=lambda path: None, )) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -796,10 +796,10 @@ def test_cleanup_dest_fileitem_is_kept_when_episode_format_matches_nothing(monke lambda fileitem, predicate: [(source_fileitem, False)], ) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -838,10 +838,10 @@ def test_episode_format_matched_but_filtered_by_size_returns_failure(monkeypatch lambda fileitem, predicate: [(source_fileitem, False)], ) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) state, errmsg = TransferChain.do_transfer( chain, @@ -879,10 +879,10 @@ def test_candidate_collection_checks_continue_callback(monkeypatch): fake_get_trans_fileitems, ) monkeypatch.setattr( - "app.chain.transfer.SystemConfigOper", + "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) - monkeypatch.setattr("app.chain._transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) + monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) state, errmsg = TransferChain.do_transfer( chain, diff --git a/tests/test_ugreen_mediaserver.py b/tests/test_ugreen_mediaserver.py index 79e214aa3..664da3040 100644 --- a/tests/test_ugreen_mediaserver.py +++ b/tests/test_ugreen_mediaserver.py @@ -162,17 +162,27 @@ class UgreenLibraryPathLimitTest(unittest.TestCase): class DashboardStatisticTest(unittest.TestCase): + class _Repository: + """Dashboard 汇总测试使用的零增量历史仓储。""" + + @staticmethod + def monthly_media_statistics(): + """返回全零月度统计,隔离媒体服务汇总断言。""" + return 0, 0, 0, 0 + @unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing") def test_statistic_all_episode_missing(self): mocked_stats = [ schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1), ] - with patch( - "app.api.endpoints.dashboard.DashboardChain.media_statistic", - return_value=mocked_stats, - ): - ret = dashboard_endpoint.statistic(name="ugreen", _=None) + from app.application.dashboard import DashboardQueryService + + service = DashboardQueryService( + repository=self._Repository(), + media_statistics=lambda _name: mocked_stats, + ) + ret = dashboard_endpoint.statistic(name="ugreen", service=service, _=None) self.assertEqual(ret.movie_count, 11) self.assertEqual(ret.tv_count, 22) @@ -185,11 +195,13 @@ class DashboardStatisticTest(unittest.TestCase): schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1), ] - with patch( - "app.api.endpoints.dashboard.DashboardChain.media_statistic", - return_value=mocked_stats, - ): - ret = dashboard_endpoint.statistic(name="all", _=None) + from app.application.dashboard import DashboardQueryService + + service = DashboardQueryService( + repository=self._Repository(), + media_statistics=lambda _name: mocked_stats, + ) + ret = dashboard_endpoint.statistic(name="all", service=service, _=None) self.assertEqual(ret.movie_count, 11) self.assertEqual(ret.tv_count, 22) diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index 6ace25a2a..4941e2baa 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -1,10 +1,9 @@ import asyncio -import json import time from queue import Queue from threading import Event as ThreadEvent from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -35,6 +34,7 @@ from app.api.endpoints.agent import ( from app.runtime.events import Event from app.db.oper.agentchat import AgentChatOper from app.db.models.agentchat import AgentChat +from app.application.messaging.chat import AgentChatService, configure_agent_chat_service from app.application.messaging.agent import build_web_agent_message_update_event from app.application.messaging.agent import AgentInteractionOption, agent_interaction_manager from app.application.messaging.skill import skill_interaction_manager @@ -167,6 +167,7 @@ def test_build_web_agent_session_id_reuses_accessible_history(): messages=[], title="Telegram 会话", ) + configure_agent_chat_service(AgentChatService(repository=AgentChatOper())) assert _build_web_agent_session_id(user, "telegram-session") == "telegram-session" @@ -389,13 +390,15 @@ def test_web_agent_admin_context_uses_current_user_id(): replay_mode=ReplyMode.CAPTURE_ONLY, ) - with patch("app.api.endpoints.agent.UserOper") as user_oper: - user_oper.return_value.async_get_by_id = AsyncMock( - return_value=SimpleNamespace(is_superuser=True) - ) + lookup_fn = Mock(return_value=SimpleNamespace(is_superuser=True)) + with patch( + "app.api.endpoints.agent.get_configured_user_id_lookup", + return_value=lookup_fn, + ) as lookup: assert asyncio.run(agent._is_system_admin_context()) is True - user_oper.return_value.async_get_by_id.assert_awaited_once_with(7) + lookup.assert_called_once_with() + lookup_fn.assert_called_once_with(7) def test_web_agent_reused_for_background_task_disables_streaming(): diff --git a/tests/test_workflow_authorization.py b/tests/test_workflow_authorization.py index 5b1953e3e..836e904e0 100644 --- a/tests/test_workflow_authorization.py +++ b/tests/test_workflow_authorization.py @@ -7,7 +7,7 @@ from fastapi import HTTPException from fastapi.routing import APIRoute from app.api.endpoints import workflow as workflow_endpoint -from app.application.security.access import verify_token +from app.adapters.web.security.access import verify_token from app.api.deps import ( get_current_active_manage_user, get_current_active_manage_user_async, diff --git a/tests/test_workflow_mutation_command.py b/tests/test_workflow_mutation_command.py index 63345f82a..cba2fc5ac 100644 --- a/tests/test_workflow_mutation_command.py +++ b/tests/test_workflow_mutation_command.py @@ -6,6 +6,7 @@ import pytest from app.application.workflow import ( WorkflowDefinitionCommand, WorkflowMutationCommand, + WorkflowQueryService, ) @@ -43,6 +44,23 @@ def _command(workflow=None, commit_error=None): return WorkflowMutationCommand(**dependencies), dependencies +@pytest.mark.asyncio +async def test_workflow_query_service_delegates_list_and_get_to_repository(): + """工作流查询服务只调用读取端口,不持有数据库会话或事务。""" + repository = Mock() + repository.async_list = AsyncMock(return_value=[_workflow()]) + repository.async_get = AsyncMock(return_value=_workflow()) + service = WorkflowQueryService(repository) + + listed = await service.list() + fetched = await service.get(7) + + assert listed == repository.async_list.return_value + assert fetched == repository.async_get.return_value + repository.async_list.assert_awaited_once_with() + repository.async_get.assert_awaited_once_with(7) + + def test_start_timer_workflow_commits_before_registering_job(): """启用定时工作流必须先提交 W 状态,再登记定时任务。""" calls = []