refactor: 收口 V3 分层架构与插件兼容边界

This commit is contained in:
jxxghp
2026-08-18 13:22:02 +08:00
parent cca99bd421
commit 8472bcff43
274 changed files with 10730 additions and 6130 deletions
+27 -21
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
"""Web 传输层认证适配器。"""
+267
View File
@@ -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")
+1 -18
View File
@@ -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 = {
+1 -1
View File
@@ -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:
+49
View File
@@ -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()
+6 -17
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+12 -7
View File
@@ -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(
+52 -65
View File
@@ -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)
+1 -1
View File
@@ -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
+26 -3
View File
@@ -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)
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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):
@@ -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
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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):
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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)
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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 (
@@ -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
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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):
+2 -2
View File
@@ -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 工具查询可用的工作流"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+2 -2
View File
@@ -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 模式则从空配置开始重建。
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+2 -2
View File
@@ -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()
+85
View File
@@ -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
+201 -47
View File
@@ -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:
"""
异步获取当前激活超级管理员
"""
+72 -71
View File
@@ -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 智能助手流式对话
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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": "请求格式错误"},
+14 -12
View File
@@ -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)
+1 -1
View File
@@ -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()
+10 -48
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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()
+25 -12
View File
@@ -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,
# 保存路径, 支持<storage>:<path>, 如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")}
+22 -73
View File
@@ -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:
"""
清空整理记录
+1 -2
View File
@@ -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 原样透传
+6 -5
View File
@@ -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(
+1 -1
View File
@@ -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
# 导入版本号
+3 -4
View File
@@ -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)
):
"""
保存分类策略配置
+9 -12
View File
@@ -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")}
+14 -24
View File
@@ -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})
+75 -77
View File
@@ -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}")
+4 -5
View File
@@ -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()
+1 -2
View File
@@ -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),
):
"""
通知渠道统一管理入口
+1 -1
View File
@@ -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 = {
+52 -43
View File
@@ -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 {}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+80 -93
View File
@@ -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:
"""
删除站点
+8 -8
View File
@@ -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:
"""
重命名文件或目录
+125 -178
View File
@@ -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:
"""
删除订阅信息
+20 -20
View File
@@ -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)):
"""
执行命令仅管理员
"""
+6 -7
View File
@@ -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()
+5 -6
View File
@@ -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),
):
"""
重新识别指定的种子
+29 -22
View File
@@ -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:
"""
根据目录样本推荐集数定位模板
+45 -38
View File
@@ -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,
+1 -1
View File
@@ -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()
+27 -26
View File
@@ -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:
"""
删除工作流
+14
View File
@@ -0,0 +1,14 @@
"""API 鉴权依赖向端点暴露的最小当前用户契约。"""
from typing import Protocol
class ApiPrincipal(Protocol):
"""隔离端点身份判断与 ORM User 实现。"""
id: int
name: str
is_superuser: bool
__all__ = ["ApiPrincipal"]
+72 -37
View File
@@ -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="未找到该电视剧!")
+127
View File
@@ -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
+10 -23
View File
@@ -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:
+176
View File
@@ -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()
+68
View File
@@ -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
+64
View File
@@ -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]
+37
View File
@@ -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
+2 -2
View File
@@ -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]
+1 -1
View File
@@ -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
+282 -15
View File
@@ -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(
+15 -7
View File
@@ -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:
+39 -2
View File
@@ -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 适配为统一媒体身份。"""
+245
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More