mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: 收口 V3 分层架构与插件兼容边界
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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,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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 适配为统一媒体身份。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Optional, List, Dict, Union
|
||||
from typing import Any, Literal, Optional, List, Dict, Protocol, Union
|
||||
from typing import Callable
|
||||
|
||||
from jinja2 import Template
|
||||
@@ -18,7 +18,7 @@ from app.runtime.config import global_vars
|
||||
from app.domain.context import MediaInfo, MusicInfo, TorrentInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
@@ -29,6 +29,64 @@ from app.foundation import size as size_tools
|
||||
from app.foundation.crypto import HashUtils
|
||||
|
||||
|
||||
class AsyncMessageQueryRepository(Protocol):
|
||||
"""消息查询用例依赖的异步持久化端口。"""
|
||||
|
||||
async def async_list_by_page(
|
||||
self, page: int = 1, count: int = 30
|
||||
) -> list[Any]:
|
||||
"""分页读取 Web 消息。"""
|
||||
...
|
||||
|
||||
async def async_list_sent_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
all_clear_before: Optional[str] = None,
|
||||
system_clear_before: Optional[str] = None,
|
||||
media_clear_before: Optional[str] = None,
|
||||
) -> list[Any]:
|
||||
"""分页读取清理水位之后的通知消息。"""
|
||||
...
|
||||
|
||||
|
||||
class MessageQueryService:
|
||||
"""封装消息历史读取与持久化对象投影。"""
|
||||
|
||||
def __init__(self, repository: AsyncMessageQueryRepository):
|
||||
"""使用显式消息查询端口初始化服务。"""
|
||||
self._repository = repository
|
||||
|
||||
async def list_web(self, page: int = 1, count: int = 20) -> list[dict[str, Any]]:
|
||||
"""分页返回可由 API schema 消费的 Web 消息字典。"""
|
||||
messages = await self._repository.async_list_by_page(page=page, count=count)
|
||||
result: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
try:
|
||||
result.append(message.to_dict())
|
||||
except Exception as error:
|
||||
logger.error(f"获取WEB消息列表失败: {str(error)}")
|
||||
return result
|
||||
|
||||
async def list_notifications(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 20,
|
||||
all_clear_before: Optional[str] = None,
|
||||
system_clear_before: Optional[str] = None,
|
||||
media_clear_before: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""分页返回清理水位之后的通知消息字典。"""
|
||||
messages = await self._repository.async_list_sent_by_page(
|
||||
page=page,
|
||||
count=count,
|
||||
all_clear_before=all_clear_before,
|
||||
system_clear_before=system_clear_before,
|
||||
media_clear_before=media_clear_before,
|
||||
)
|
||||
return [message.to_dict() for message in messages]
|
||||
|
||||
|
||||
class TemplateContextBuilder:
|
||||
"""
|
||||
模板上下文构建器。
|
||||
@@ -722,7 +780,7 @@ class MessageTemplateHelper:
|
||||
获取消息模板
|
||||
"""
|
||||
try:
|
||||
template_dict = SystemConfigOper().get(SystemConfigKey.NotificationTemplates) or {}
|
||||
template_dict = get_configured_system_config().get(SystemConfigKey.NotificationTemplates) or {}
|
||||
if isinstance(template_dict, dict):
|
||||
configured = template_dict.get(message.ctype.value)
|
||||
if str(configured or "").strip() not in {"", "{}", "{ }"}:
|
||||
@@ -766,7 +824,7 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
初始化配置
|
||||
"""
|
||||
self.schedule_periods = self._parse_schedule(
|
||||
SystemConfigOper().get(SystemConfigKey.NotificationSendTime)
|
||||
get_configured_system_config().get(SystemConfigKey.NotificationSendTime)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import re
|
||||
from typing import Callable, List, Optional, Tuple, Union
|
||||
from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
|
||||
|
||||
from app.db.models.site import Site
|
||||
from app.db.oper.site import SiteOper
|
||||
from app.domain import site as site_rules
|
||||
from app.application.messaging.interaction import (
|
||||
MessageGateway,
|
||||
@@ -22,6 +20,19 @@ from app.schemas.types import NotificationChannel
|
||||
site_interaction_manager = SlashInteractionManager()
|
||||
|
||||
|
||||
class SiteInteractionRepository(Protocol):
|
||||
"""站点消息交互所需的同步数据端口。"""
|
||||
|
||||
def list(self) -> List[Any]:
|
||||
"""返回站点列表。"""
|
||||
|
||||
def get(self, site_id: int) -> Optional[Any]:
|
||||
"""按 ID 返回站点。"""
|
||||
|
||||
def update(self, site_id: int, payload: dict) -> Optional[Any]:
|
||||
"""更新站点。"""
|
||||
|
||||
|
||||
class SiteInteractionHandler:
|
||||
"""
|
||||
管理 /sites 交互会话、输入解析和站点列表渲染。
|
||||
@@ -34,12 +45,14 @@ class SiteInteractionHandler:
|
||||
self,
|
||||
messenger: MessageGateway,
|
||||
cookie_updater: Callable[..., Tuple[bool, str]],
|
||||
repository: SiteInteractionRepository,
|
||||
):
|
||||
"""
|
||||
注入消息投递接口和站点 Cookie 更新动作。
|
||||
"""
|
||||
self._messenger = messenger
|
||||
self._cookie_updater = cookie_updater
|
||||
self._repository = repository
|
||||
|
||||
def remote_list(
|
||||
self,
|
||||
@@ -400,7 +413,7 @@ class SiteInteractionHandler:
|
||||
"""
|
||||
渲染 /sites 当前页面。
|
||||
"""
|
||||
site_list = SiteOper().list()
|
||||
site_list = self._repository.list()
|
||||
page_size = self._button_page_size if supports_interaction_buttons(channel) else self._text_page_size
|
||||
page_sites, page, total_pages = page_items(site_list, request.page, page_size)
|
||||
request.page = page
|
||||
@@ -463,7 +476,7 @@ class SiteInteractionHandler:
|
||||
|
||||
@staticmethod
|
||||
def _format_site_list(
|
||||
site_list: List[Site], channel: Optional[NotificationChannel]
|
||||
site_list: List[Any], channel: Optional[NotificationChannel]
|
||||
) -> str:
|
||||
"""
|
||||
根据渠道能力格式化站点列表。
|
||||
@@ -537,15 +550,14 @@ class SiteInteractionHandler:
|
||||
if not site_ids:
|
||||
return False, "请输入至少一个有效的站点 ID"
|
||||
|
||||
siteoper = SiteOper()
|
||||
changed = []
|
||||
missing = []
|
||||
for site_id in site_ids:
|
||||
site = siteoper.get(site_id)
|
||||
site = self._repository.get(site_id)
|
||||
if not site:
|
||||
missing.append(str(site_id))
|
||||
continue
|
||||
siteoper.update(site_id, {"is_active": enabled})
|
||||
self._repository.update(site_id, {"is_active": enabled})
|
||||
changed.append(site.name)
|
||||
|
||||
action = "启用" if enabled else "禁用"
|
||||
@@ -571,7 +583,7 @@ class SiteInteractionHandler:
|
||||
)
|
||||
|
||||
site_id = int(args[0])
|
||||
site_info = SiteOper().get(site_id)
|
||||
site_info = self._repository.get(site_id)
|
||||
if not site_info:
|
||||
return False, f"站点编号 {site_id} 不存在"
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union
|
||||
|
||||
from app.agent.skills.registry import SkillHelper, SkillInfo
|
||||
from app.application.messaging.interaction import (
|
||||
MessageGateway,
|
||||
build_navigation_buttons,
|
||||
@@ -17,6 +16,54 @@ from app.schemas.message import Message
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
class SkillCatalogPort(Protocol):
|
||||
"""消息层使用的技能目录能力端口,由启动组合根注入具体实现。"""
|
||||
|
||||
def add_custom_market_source(self, source: str) -> Tuple[bool, str]:
|
||||
"""添加一个自定义技能市场来源。"""
|
||||
|
||||
def remove_custom_market_source(self, source: str) -> Tuple[bool, str]:
|
||||
"""移除一个自定义技能市场来源。"""
|
||||
|
||||
def install_market_skill(self, skill: Any) -> Tuple[bool, str]:
|
||||
"""安装指定的市场技能。"""
|
||||
|
||||
def list_local_skills(self) -> List[Any]:
|
||||
"""列出本地技能。"""
|
||||
|
||||
def remove_local_skill(self, skill_id: str) -> Tuple[bool, str]:
|
||||
"""移除指定的本地技能。"""
|
||||
|
||||
def list_market_source_entries(self) -> List[Any]:
|
||||
"""列出技能市场来源。"""
|
||||
|
||||
def list_market_skills(self, force: bool = False) -> List[Any]:
|
||||
"""列出市场技能。"""
|
||||
|
||||
def filter_market_skills(self, skills: List[Any], query: str) -> List[Any]:
|
||||
"""按查询词过滤市场技能。"""
|
||||
|
||||
|
||||
SkillCatalogProvider = Callable[[], SkillCatalogPort]
|
||||
_skill_catalog_provider: Optional[SkillCatalogProvider] = None
|
||||
|
||||
|
||||
def register_skill_catalog_provider(provider: SkillCatalogProvider) -> None:
|
||||
"""由启动组合根注册 Agent 技能目录实现,避免消息层依赖 Agent 具体模块。"""
|
||||
global _skill_catalog_provider
|
||||
_skill_catalog_provider = provider
|
||||
|
||||
|
||||
def _resolve_skill_catalog() -> SkillCatalogPort:
|
||||
"""解析已注入的技能目录;缺少组合根装配时给出明确错误。"""
|
||||
if _skill_catalog_provider is None:
|
||||
raise RuntimeError(
|
||||
"技能目录服务未注册:请先导入 app.startup.agent_initializer "
|
||||
"完成组合根装配"
|
||||
)
|
||||
return _skill_catalog_provider()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingSkillInteraction:
|
||||
"""
|
||||
@@ -152,11 +199,12 @@ class SkillInteractionHandler:
|
||||
def __init__(
|
||||
self,
|
||||
messenger: MessageGateway,
|
||||
skill_helper: Optional[SkillHelper] = None,
|
||||
skill_catalog: Optional[SkillCatalogPort] = None,
|
||||
skill_helper: Optional[SkillCatalogPort] = None,
|
||||
):
|
||||
"""注入消息接口和技能管理能力。"""
|
||||
"""注入消息接口和技能目录端口,保留旧 ``skill_helper`` 关键字。"""
|
||||
self._messenger = messenger
|
||||
self.skillhelper = skill_helper or SkillHelper()
|
||||
self.skillhelper = skill_catalog or skill_helper or _resolve_skill_catalog()
|
||||
|
||||
def remote_manage(
|
||||
self,
|
||||
@@ -1059,10 +1107,10 @@ class SkillInteractionHandler:
|
||||
|
||||
@staticmethod
|
||||
def _page_items(
|
||||
items: List[SkillInfo],
|
||||
items: List[Any],
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> Tuple[List[SkillInfo], int, int]:
|
||||
) -> Tuple[List[Any], int, int]:
|
||||
"""
|
||||
返回当前页的数据,并把页码钳制到有效范围内。
|
||||
"""
|
||||
@@ -1146,7 +1194,7 @@ class SkillInteractionHandler:
|
||||
self,
|
||||
request: PendingSkillInteraction,
|
||||
force_market_refresh: bool = False,
|
||||
) -> List[SkillInfo]:
|
||||
) -> List[Any]:
|
||||
"""
|
||||
获取当前 /skills 会话可见的市场技能,并应用搜索词过滤。
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
from typing import List, Optional, Protocol, Tuple, Union
|
||||
from typing import Any, Callable, List, Optional, Protocol, Tuple, Union
|
||||
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.application.messaging.interaction import (
|
||||
MessageGateway,
|
||||
SlashInteractionManager,
|
||||
@@ -12,8 +11,6 @@ from app.application.messaging.interaction import (
|
||||
supports_markdown,
|
||||
update_or_post_message,
|
||||
)
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import NotificationChannel, MediaType
|
||||
|
||||
@@ -30,6 +27,19 @@ class SubscribeInteractionActions(Protocol):
|
||||
"""执行订阅刷新。"""
|
||||
...
|
||||
|
||||
|
||||
class SubscribeInteractionRepository(Protocol):
|
||||
"""订阅消息交互所需的同步数据端口。"""
|
||||
|
||||
def list(self) -> List[Any]:
|
||||
"""返回订阅列表。"""
|
||||
|
||||
def get(self, subscribe_id: int) -> Optional[Any]:
|
||||
"""按 ID 返回订阅。"""
|
||||
|
||||
def delete(self, subscribe_id: int) -> Any:
|
||||
"""删除订阅。"""
|
||||
|
||||
def check(self):
|
||||
"""执行订阅元数据检查。"""
|
||||
...
|
||||
@@ -51,12 +61,16 @@ class SubscribeInteractionHandler:
|
||||
self,
|
||||
messenger: MessageGateway,
|
||||
actions: SubscribeInteractionActions,
|
||||
repository: SubscribeInteractionRepository,
|
||||
report_deleted: Callable[[dict], Any],
|
||||
):
|
||||
"""
|
||||
注入消息投递接口和订阅业务动作。
|
||||
"""
|
||||
self._messenger = messenger
|
||||
self._actions = actions
|
||||
self._repository = repository
|
||||
self._report_deleted = report_deleted
|
||||
|
||||
def remote_list(
|
||||
self,
|
||||
@@ -401,7 +415,7 @@ class SubscribeInteractionHandler:
|
||||
"""
|
||||
渲染 /subscribes 当前页面。
|
||||
"""
|
||||
subscribes = SubscribeOper().list()
|
||||
subscribes = self._repository.list()
|
||||
page_size = (
|
||||
self._button_page_size
|
||||
if supports_interaction_buttons(channel)
|
||||
@@ -475,7 +489,7 @@ class SubscribeInteractionHandler:
|
||||
)
|
||||
|
||||
def _format_subscribe_list(
|
||||
self, subscribes: List[Subscribe], channel: Optional[NotificationChannel]
|
||||
self, subscribes: List[Any], channel: Optional[NotificationChannel]
|
||||
) -> str:
|
||||
"""
|
||||
根据渠道能力格式化订阅列表。
|
||||
@@ -521,7 +535,7 @@ class SubscribeInteractionHandler:
|
||||
return mapping.get(state or "", state or "-")
|
||||
|
||||
@staticmethod
|
||||
def _format_subscribe_progress(subscribe: Subscribe) -> str:
|
||||
def _format_subscribe_progress(subscribe: Any) -> str:
|
||||
"""
|
||||
构造订阅的季和进度说明。
|
||||
"""
|
||||
@@ -658,11 +672,10 @@ class SubscribeInteractionHandler:
|
||||
if not subscribe_ids:
|
||||
return False, "请输入订阅 ID,多个 ID 用空格分隔,或输入 all"
|
||||
|
||||
subscribeoper = SubscribeOper()
|
||||
missing = []
|
||||
searched = []
|
||||
for subscribe_id in subscribe_ids:
|
||||
subscribe = subscribeoper.get(subscribe_id)
|
||||
subscribe = self._repository.get(subscribe_id)
|
||||
if not subscribe:
|
||||
missing.append(str(subscribe_id))
|
||||
continue
|
||||
@@ -696,17 +709,16 @@ class SubscribeInteractionHandler:
|
||||
if not subscribe_ids:
|
||||
return False, "请输入至少一个有效的订阅 ID"
|
||||
|
||||
subscribeoper = SubscribeOper()
|
||||
deleted = []
|
||||
missing = []
|
||||
for subscribe_id in subscribe_ids:
|
||||
subscribe = subscribeoper.get(subscribe_id)
|
||||
subscribe = self._repository.get(subscribe_id)
|
||||
if not subscribe:
|
||||
missing.append(str(subscribe_id))
|
||||
continue
|
||||
deleted.append(subscribe.name)
|
||||
subscribeoper.delete(subscribe_id)
|
||||
MoviePilotServerHelper.sub_done_async(
|
||||
self._repository.delete(subscribe_id)
|
||||
self._report_deleted(
|
||||
{
|
||||
"media_source": subscribe.media_source,
|
||||
"media_id": subscribe.media_id,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""宿主模块目录的应用层端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class ModuleRuntime(Protocol):
|
||||
"""声明入口层消费的模块目录能力。"""
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""允许兼容门面访问既有模块管理方法。"""
|
||||
|
||||
|
||||
ModuleRuntimeProvider = Callable[[], ModuleRuntime]
|
||||
|
||||
|
||||
def _unconfigured_runtime() -> ModuleRuntime:
|
||||
"""拒绝在组合根装配前隐式创建模块管理器。"""
|
||||
raise RuntimeError("宿主模块运行时尚未由启动组合根装配")
|
||||
|
||||
|
||||
_runtime_provider: ModuleRuntimeProvider = _unconfigured_runtime
|
||||
|
||||
|
||||
def configure_module_runtime(provider: ModuleRuntimeProvider) -> None:
|
||||
"""由启动组合根注册模块运行时实例提供器。"""
|
||||
global _runtime_provider
|
||||
_runtime_provider = provider
|
||||
|
||||
|
||||
def get_module_manager() -> ModuleRuntime:
|
||||
"""返回当前组合根提供的模块目录能力。"""
|
||||
return _runtime_provider()
|
||||
|
||||
|
||||
class _ModuleRuntimeProxy(type):
|
||||
"""把历史 ``ModuleManager`` 调用转发到应用端口。"""
|
||||
|
||||
def __getattr__(cls, name: str) -> Any:
|
||||
"""转发旧的类级静态调用。"""
|
||||
return getattr(get_module_manager(), name)
|
||||
|
||||
|
||||
class ModuleManager(metaclass=_ModuleRuntimeProxy):
|
||||
"""应用层兼容门面,实例调用返回组合根装配的模块管理器。"""
|
||||
|
||||
def __new__(cls) -> ModuleRuntime:
|
||||
"""返回实际模块管理器,不复制运行态注册表。"""
|
||||
return get_module_manager()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ModuleManager",
|
||||
"ModuleRuntime",
|
||||
"configure_module_runtime",
|
||||
"get_module_manager",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.runtime.extensions.service_registry import ServiceBaseHelper
|
||||
from app.application.service import ServiceBaseHelper
|
||||
from app.schemas.system import NotificationConf
|
||||
from app.schemas.system import ServiceInfo
|
||||
from app.schemas.types import ModuleType, SystemConfigKey
|
||||
|
||||
@@ -13,3 +13,7 @@ class DynamicRouteRegistry(Protocol):
|
||||
def remove(self, plugin_id: str) -> bool:
|
||||
"""移除指定插件的全部动态路由。"""
|
||||
...
|
||||
|
||||
def clean(self, existing_paths: dict) -> None:
|
||||
"""清理重建过程中可能重复的受保护路由。"""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""插件运行时目录的应用层端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class PluginRuntime(Protocol):
|
||||
"""声明入口层消费的插件宿主能力。"""
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""允许兼容门面按既有 V3 方法名访问插件宿主能力。"""
|
||||
|
||||
|
||||
PluginRuntimeProvider = Callable[[], PluginRuntime]
|
||||
|
||||
|
||||
def _unconfigured_runtime() -> PluginRuntime:
|
||||
"""拒绝在启动组合根完成前隐式创建 Runtime 管理器。"""
|
||||
raise RuntimeError("插件运行时尚未由启动组合根装配")
|
||||
|
||||
|
||||
_runtime_provider: PluginRuntimeProvider = _unconfigured_runtime
|
||||
|
||||
|
||||
def configure_plugin_runtime(provider: PluginRuntimeProvider) -> None:
|
||||
"""由启动组合根注册插件运行时实例提供器。"""
|
||||
global _runtime_provider
|
||||
_runtime_provider = provider
|
||||
|
||||
|
||||
def get_plugin_manager() -> PluginRuntime:
|
||||
"""返回当前组合根提供的插件运行时能力。"""
|
||||
return _runtime_provider()
|
||||
+16
-44
@@ -11,53 +11,25 @@ FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||
from app.application.security.access import verify_apikey, verify_token
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.application.plugin.routes import DynamicRouteRegistry
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
PROTECTED_ROUTES = {
|
||||
"/api/v1/openapi.json",
|
||||
"/docs",
|
||||
"/docs/oauth2-redirect",
|
||||
"/redoc",
|
||||
}
|
||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||
|
||||
# FastAPI 应用实例:由 factory 在创建应用后调用 register_api_app 注入。
|
||||
_api_app: Optional[FastAPI] = None
|
||||
_route_registry: Optional[DynamicRouteRegistry] = None
|
||||
|
||||
|
||||
def register_api_app(api_app: FastAPI) -> None:
|
||||
"""注入 FastAPI 应用实例(组合根在创建应用后调用)。"""
|
||||
global _api_app
|
||||
_api_app = api_app
|
||||
def configure_plugin_routes(registry: DynamicRouteRegistry) -> None:
|
||||
"""由 HTTP 组合根注入动态插件路由适配器。"""
|
||||
global _route_registry
|
||||
_route_registry = registry
|
||||
|
||||
|
||||
def get_api_app() -> FastAPI:
|
||||
"""返回已注入的 FastAPI 应用实例。"""
|
||||
if _api_app is None:
|
||||
raise RuntimeError("插件路由服务未初始化:请先调用 register_api_app 注入应用实例")
|
||||
return _api_app
|
||||
|
||||
|
||||
def _route_registry() -> FastAPIDynamicRouteRegistry:
|
||||
"""组装绑定当前 FastAPI 应用与插件管理器的动态路由适配器。"""
|
||||
return FastAPIDynamicRouteRegistry(
|
||||
app=get_api_app(),
|
||||
plugin_ids=lambda: PluginManager().get_running_plugin_ids(),
|
||||
plugin_apis=lambda plugin_id: PluginManager().get_plugin_apis(plugin_id),
|
||||
verify_token=verify_token,
|
||||
verify_apikey=verify_apikey,
|
||||
prefix=PLUGIN_PREFIX,
|
||||
protected_routes=PROTECTED_ROUTES,
|
||||
log=logger,
|
||||
)
|
||||
def _get_route_registry() -> DynamicRouteRegistry:
|
||||
"""返回已注入的动态插件路由端口。"""
|
||||
if _route_registry is None:
|
||||
raise RuntimeError("插件路由服务尚未由 HTTP 组合根配置")
|
||||
return _route_registry
|
||||
|
||||
|
||||
def register_plugin_api(plugin_id: Optional[str] = None) -> None:
|
||||
@@ -83,7 +55,7 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None:
|
||||
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
|
||||
:param action: "add" 或 "remove",决定是添加还是移除路由
|
||||
"""
|
||||
_route_registry().update(plugin_id, action)
|
||||
_get_route_registry().update(plugin_id, action)
|
||||
|
||||
|
||||
def _remove_routes(plugin_id: str) -> bool:
|
||||
@@ -92,7 +64,7 @@ def _remove_routes(plugin_id: str) -> bool:
|
||||
:param plugin_id: 插件 ID
|
||||
:return: 是否有路由被移除
|
||||
"""
|
||||
return _route_registry().remove(plugin_id)
|
||||
return _get_route_registry().remove(plugin_id)
|
||||
|
||||
|
||||
def _clean_protected_routes(existing_paths: dict) -> None:
|
||||
@@ -100,7 +72,7 @@ def _clean_protected_routes(existing_paths: dict) -> None:
|
||||
清理受保护的路由,防止在插件操作中被删除或重复添加
|
||||
:param existing_paths: 当前应用的路由路径映射
|
||||
"""
|
||||
_route_registry().clean(existing_paths)
|
||||
_get_route_registry().clean(existing_paths)
|
||||
|
||||
|
||||
def remove_plugin_from_folders(plugin_id: str):
|
||||
@@ -109,7 +81,7 @@ def remove_plugin_from_folders(plugin_id: str):
|
||||
:param plugin_id: 要移除的插件ID
|
||||
"""
|
||||
try:
|
||||
config_oper = SystemConfigOper()
|
||||
config_oper = get_configured_system_config()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class RecognitionRuleService:
|
||||
"""集中读取用户持久化的媒体识别规则,供启动层注入纯领域匹配器。"""
|
||||
|
||||
def __init__(self, systemconfig: Optional[SystemConfigOper] = None) -> None:
|
||||
def __init__(self, systemconfig: Optional[Any] = None) -> None:
|
||||
"""绑定系统配置访问器,测试可传入隔离替身。"""
|
||||
self._systemconfig = systemconfig or SystemConfigOper()
|
||||
self._systemconfig = systemconfig
|
||||
|
||||
def _config(self) -> Any:
|
||||
"""惰性获取配置服务,避免引导阶段早于组合根装配。"""
|
||||
return self._systemconfig or get_configured_system_config()
|
||||
|
||||
def get_customization(self) -> object:
|
||||
"""返回当前自定义占位符配置。"""
|
||||
return self._systemconfig.get(SystemConfigKey.Customization)
|
||||
return self._config().get(SystemConfigKey.Customization)
|
||||
|
||||
def get_release_groups(self) -> object:
|
||||
"""返回当前用户自定义制作组配置。"""
|
||||
return self._systemconfig.get(SystemConfigKey.CustomReleaseGroups)
|
||||
return self._config().get(SystemConfigKey.CustomReleaseGroups)
|
||||
|
||||
def get_custom_words(self) -> object:
|
||||
"""返回当前自定义识别词配置。"""
|
||||
return self._systemconfig.get(SystemConfigKey.CustomIdentifiers)
|
||||
return self._config().get(SystemConfigKey.CustomIdentifiers)
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Dict, List, Optional
|
||||
from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, alphanums, Combine, nums, ParseResults
|
||||
|
||||
from app.adapters.system import rust as rust_accel
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.rule import CustomRule
|
||||
from app.schemas.system import FilterRuleGroup
|
||||
@@ -22,7 +22,7 @@ class RuleHelper:
|
||||
@staticmethod
|
||||
def get_rule_groups() -> List[FilterRuleGroup]:
|
||||
"""返回用户配置的全部过滤规则组。"""
|
||||
rule_groups: List[dict] = SystemConfigOper().get(
|
||||
rule_groups: List[dict] = get_configured_system_config().get(
|
||||
SystemConfigKey.UserFilterRuleGroups
|
||||
)
|
||||
if not rule_groups:
|
||||
@@ -63,7 +63,7 @@ class RuleHelper:
|
||||
@staticmethod
|
||||
def get_custom_rules() -> List[CustomRule]:
|
||||
"""返回用户配置的全部自定义过滤规则。"""
|
||||
rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)
|
||||
rules: List[dict] = get_configured_system_config().get(SystemConfigKey.CustomFilterRules)
|
||||
if not rules:
|
||||
return []
|
||||
return [CustomRule(**rule) for rule in rules]
|
||||
|
||||
@@ -33,6 +33,14 @@ def get_scheduler() -> Any:
|
||||
return _scheduler_class()
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""应用层调度器兼容门面,不直接导入顶层 Scheduler 实现。"""
|
||||
|
||||
def __new__(cls) -> Any:
|
||||
"""返回组合根注册的调度器实例。"""
|
||||
return get_scheduler()
|
||||
|
||||
|
||||
def list_scheduler_jobs() -> List[Any]:
|
||||
"""列出运行时调度器的全部任务。"""
|
||||
return get_scheduler().list()
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
from typing import Any, Union, Annotated, Optional, Callable
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import HTTPException, status, Security, Request, Response
|
||||
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie, HTTPBearer
|
||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||
from app.runtime.cache import cached
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.log import logger
|
||||
|
||||
BCRYPT_PASSWORD_MAX_BYTES = 72
|
||||
BCRYPT_ROUNDS = 12
|
||||
ALGORITHM = "HS256"
|
||||
SuperuserTokenPayloadProvider = Callable[[], _SchemaTokenPayload]
|
||||
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
|
||||
|
||||
|
||||
class PasswordTooLongError(ValueError):
|
||||
"""密码的 UTF-8 字节长度超过 bcrypt 可安全处理的上限。"""
|
||||
|
||||
|
||||
def _encode_bcrypt_password(
|
||||
password: str, *, allow_legacy_truncation: bool = False
|
||||
) -> bytes:
|
||||
"""编码 bcrypt 密码;仅验证既有哈希时允许按历史语义截断。"""
|
||||
password_bytes = password.encode("utf-8")
|
||||
if len(password_bytes) > BCRYPT_PASSWORD_MAX_BYTES:
|
||||
if allow_legacy_truncation:
|
||||
return password_bytes[:BCRYPT_PASSWORD_MAX_BYTES]
|
||||
raise PasswordTooLongError(
|
||||
f"密码 UTF-8 编码后不能超过 {BCRYPT_PASSWORD_MAX_BYTES} 字节"
|
||||
)
|
||||
return password_bytes
|
||||
|
||||
|
||||
def set_superuser_token_payload_provider(
|
||||
provider: SuperuserTokenPayloadProvider,
|
||||
) -> None:
|
||||
"""注入 API 密钥认证所需的超级用户载荷提供器。"""
|
||||
global _superuser_token_payload_provider
|
||||
_superuser_token_payload_provider = provider
|
||||
|
||||
# OAuth2PasswordBearer 用于 JWT Token 认证
|
||||
oauth2_scheme_manual_error = OAuth2PasswordBearer(
|
||||
auto_error=False, # 禁用自动错误处理,用以支持API令牌鉴权
|
||||
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
||||
)
|
||||
|
||||
# RESOURCE TOKEN 通过 Cookie 认证
|
||||
resource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name="resource_token_cookie")
|
||||
|
||||
# API TOKEN 通过 QUERY 认证
|
||||
api_token_query = APIKeyQuery(name="token", auto_error=False, scheme_name="api_token_query")
|
||||
|
||||
# API KEY 通过 Header 认证
|
||||
api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="api_key_header")
|
||||
|
||||
# API KEY 通过 QUERY 认证
|
||||
api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query")
|
||||
|
||||
# OpenAI compatible Bearer Token 认证
|
||||
openai_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
# Anthropic compatible API Key 认证
|
||||
anthropic_api_key_header = APIKeyHeader(name="x-api-key", auto_error=False, scheme_name="anthropic_api_key_header")
|
||||
|
||||
|
||||
def __get_api_token(
|
||||
token_query: Annotated[str | None, Security(api_token_query)] = None
|
||||
) -> str | None:
|
||||
"""
|
||||
从 URL 查询参数中获取 API Token
|
||||
:param token_query: 从 URL 中的 `token` 查询参数获取 API Token
|
||||
:return: 返回获取到的 API Token,若无则返回 None
|
||||
"""
|
||||
return token_query
|
||||
|
||||
|
||||
def __get_api_key(
|
||||
key_query: Annotated[str | None, Security(api_key_query)] = None,
|
||||
key_header: Annotated[str | None, Security(api_key_header)] = None
|
||||
) -> str | None:
|
||||
"""
|
||||
从 URL 查询参数或请求头部获取 API Key,优先使用请求头
|
||||
:param key_query: URL 中的 `apikey` 查询参数
|
||||
:param key_header: 请求头中的 `X-API-KEY` 参数
|
||||
:return: 返回从 URL 或请求头中获取的 API Key,若无则返回 None
|
||||
"""
|
||||
return key_header or key_query # 首选请求头
|
||||
|
||||
|
||||
@cached(maxsize=1, ttl=600)
|
||||
def __create_superuser_token_payload() -> _SchemaTokenPayload:
|
||||
"""
|
||||
创建管理员用户的TokenPayload
|
||||
|
||||
:return: 管理员TokenPayload
|
||||
"""
|
||||
if not _superuser_token_payload_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="认证服务尚未初始化",
|
||||
)
|
||||
return _superuser_token_payload_provider()
|
||||
|
||||
|
||||
def create_access_token(
|
||||
userid: Union[str, Any],
|
||||
username: str,
|
||||
super_user: Optional[bool] = False,
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
level: Optional[int] = 1,
|
||||
purpose: Optional[str] = "authentication"
|
||||
) -> str:
|
||||
"""
|
||||
创建 JWT 访问令牌,包含用户 ID、用户名、是否为超级用户以及权限等级
|
||||
:param userid: 用户的唯一标识符,通常是字符串或整数
|
||||
:param username: 用户名,用于标识用户的账户名
|
||||
:param super_user: 是否为超级用户,默认值为 False
|
||||
:param expires_delta: 令牌的有效期时长,如果不提供则根据用途使用默认过期时间
|
||||
:param level: 用户的权限级别,默认为 1
|
||||
:param purpose: 令牌的用途,"authentication" 或 "resource"
|
||||
:return: 编码后的 JWT 令牌字符串
|
||||
:raises ValueError: 如果 expires_delta 为负数
|
||||
"""
|
||||
if purpose == "resource":
|
||||
default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
secret_key = settings.RESOURCE_SECRET_KEY
|
||||
else:
|
||||
default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
secret_key = settings.SECRET_KEY
|
||||
|
||||
if expires_delta is not None:
|
||||
if expires_delta.total_seconds() <= 0:
|
||||
raise ValueError("过期时间必须为正数")
|
||||
expire = datetime.datetime.now(datetime.UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.datetime.now(datetime.UTC) + default_expire
|
||||
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"iat": datetime.datetime.now(datetime.UTC),
|
||||
"sub": str(userid),
|
||||
"username": username,
|
||||
"super_user": super_user,
|
||||
"level": level,
|
||||
"purpose": purpose
|
||||
}
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def set_or_refresh_resource_token_cookie(
|
||||
request: Request, response: Response, payload: _SchemaTokenPayload
|
||||
) -> None:
|
||||
"""
|
||||
设置资源令牌 Cookie
|
||||
:param request: 包含请求相关的上下文数据
|
||||
:param response: 用于在服务器响应时设置 Cookie
|
||||
:param payload: 已通过身份验证的 TokenPayload 对象
|
||||
"""
|
||||
resource_token = request.cookies.get(settings.PROJECT_NAME)
|
||||
|
||||
if resource_token:
|
||||
# 检查令牌剩余时间
|
||||
try:
|
||||
decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM])
|
||||
exp = decoded_token.get("exp")
|
||||
if exp:
|
||||
remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC)
|
||||
# 根据剩余时长提前刷新令牌
|
||||
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
|
||||
raise jwt.ExpiredSignatureError
|
||||
expected_claims = {
|
||||
"sub": str(payload.sub),
|
||||
"username": payload.username,
|
||||
"super_user": payload.super_user,
|
||||
"level": payload.level,
|
||||
"purpose": "resource",
|
||||
}
|
||||
if any(decoded_token.get(claim) != value for claim, value in expected_claims.items()):
|
||||
raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配")
|
||||
except jwt.PyJWTError:
|
||||
logger.debug(f"Token error occurred. refreshing token")
|
||||
except Exception as e:
|
||||
logger.debug(f"Unexpected error occurred while decoding token: {e}")
|
||||
else:
|
||||
# 如果令牌有效且没有即将过期,则不需要刷新
|
||||
return
|
||||
|
||||
# 创建新的资源访问令牌
|
||||
resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
resource_token = create_access_token(
|
||||
userid=payload.sub,
|
||||
username=payload.username,
|
||||
super_user=payload.super_user,
|
||||
expires_delta=resource_token_expires,
|
||||
level=payload.level,
|
||||
purpose="resource"
|
||||
)
|
||||
|
||||
# 判断请求是否为 HTTPS:直连协议为 https,或经反向代理转发时携带 X-Forwarded-Proto: https。
|
||||
# 无法确认为明文 HTTP 时按 fail-safe 默认设置 secure=True,避免代理终止 HTTPS 后以 HTTP 转发导致 Cookie 明文传输。
|
||||
is_https = (
|
||||
request.url.scheme == "https"
|
||||
or request.headers.get("x-forwarded-proto", "").lower() == "https"
|
||||
)
|
||||
|
||||
# 设置会话级别的 HttpOnly Cookie
|
||||
response.set_cookie(
|
||||
key=settings.PROJECT_NAME,
|
||||
value=resource_token,
|
||||
httponly=True,
|
||||
secure=is_https, # 根据当前请求协议(含反向代理转发标识)设置 secure 属性
|
||||
samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性
|
||||
)
|
||||
|
||||
|
||||
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> _SchemaTokenPayload:
|
||||
"""
|
||||
使用 JWT Token 进行身份认证并解析 Token 的内容
|
||||
:param token: JWT 令牌
|
||||
:param purpose: 期望的令牌用途,默认为 "authentication"
|
||||
:return: 包含用户身份信息的 Token 负载数据
|
||||
:raises HTTPException: 如果令牌无效或用途不匹配
|
||||
"""
|
||||
try:
|
||||
if purpose == "resource":
|
||||
secret_key = settings.RESOURCE_SECRET_KEY
|
||||
else:
|
||||
secret_key = settings.SECRET_KEY
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"{purpose} token not found"
|
||||
)
|
||||
|
||||
payload = jwt.decode(
|
||||
token, secret_key, algorithms=[ALGORITHM]
|
||||
)
|
||||
|
||||
token_payload = _SchemaTokenPayload(**payload)
|
||||
|
||||
if token_payload.purpose != purpose:
|
||||
raise jwt.InvalidTokenError("令牌用途不匹配")
|
||||
|
||||
return _SchemaTokenPayload(**payload)
|
||||
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="token校验不通过",
|
||||
)
|
||||
|
||||
|
||||
def verify_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)],
|
||||
api_key: Annotated[str | None, Security(__get_api_key)],
|
||||
api_token: Annotated[str | None, Security(__get_api_token)],
|
||||
) -> _SchemaTokenPayload:
|
||||
"""
|
||||
验证 JWT 令牌并自动处理 resource_token 写入
|
||||
|
||||
如果缺少JWT令牌再尝试用API令牌鉴权
|
||||
|
||||
:param request: 请求对象,用于访问 Cookie 和请求信息
|
||||
:param response: 响应对象,用于设置 Cookie
|
||||
:param jwt_token: 从 Authorization 头部获取的 JWT 令牌
|
||||
:param api_key: 从 查询参数`apikey` 或 请求头`X-API-KEY` 获取 API Token
|
||||
:param api_token: 从 查询参数`token` 获取 API Token
|
||||
:return: 解析后的 TokenPayload
|
||||
:raises HTTPException: 如果令牌无效或用途不匹配
|
||||
"""
|
||||
if jwt_token:
|
||||
# 验证并解析 JWT 认证令牌
|
||||
payload = __verify_token(token=jwt_token, purpose="authentication")
|
||||
|
||||
# 如果没有 resource_token,生成并写入到 Cookie
|
||||
set_or_refresh_resource_token_cookie(request, response, payload)
|
||||
|
||||
return payload
|
||||
elif api_key:
|
||||
verify_apikey(api_key)
|
||||
return __create_superuser_token_payload()
|
||||
elif api_token:
|
||||
verify_apitoken(api_token)
|
||||
return __create_superuser_token_payload()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def verify_resource_token(
|
||||
resource_token: Annotated[str, Security(resource_token_cookie)]
|
||||
) -> _SchemaTokenPayload:
|
||||
"""
|
||||
验证资源访问令牌(从 Cookie 中获取)
|
||||
:param resource_token: 从 Cookie 中获取的资源访问令牌
|
||||
:return: 解析后的 TokenPayload
|
||||
:raises HTTPException: 如果资源访问令牌无效
|
||||
"""
|
||||
# 验证并解析资源访问令牌
|
||||
return __verify_token(token=resource_token, purpose="resource")
|
||||
|
||||
|
||||
def __verify_key(key: str | None, expected_key: str, key_type: str) -> str:
|
||||
"""
|
||||
通用的 API Key 或 Token 验证函数
|
||||
:param key: 从请求中获取的 API Key 或 Token
|
||||
:param expected_key: 系统配置中的期望值,用于验证的 API Key 或 Token
|
||||
:param key_type: 键的类型(例如 "API_KEY" 或 "API_TOKEN"),用于错误消息
|
||||
:return: 返回校验通过的 API Key 或 Token
|
||||
:raises HTTPException: 如果校验不通过,抛出 401 错误
|
||||
"""
|
||||
if not key or key != expected_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"{key_type} 校验不通过"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str:
|
||||
"""
|
||||
使用 API Token 进行受信第三方集成认证。
|
||||
|
||||
校验值来自 settings.API_TOKEN;通过后只确认集成凭据有效,不生成 per-user 权限上下文。
|
||||
:param token: API Token,从 URL 查询参数中获取 token=xxx
|
||||
:return: 返回校验通过的 API Token
|
||||
"""
|
||||
return __verify_key(token, settings.API_TOKEN, "token")
|
||||
|
||||
|
||||
def verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str:
|
||||
"""
|
||||
使用 API Key 形式进行受信第三方集成认证。
|
||||
|
||||
请求字段名兼容 API Key,实际校验值来自 settings.API_TOKEN,不生成 per-user 权限上下文。
|
||||
:param apikey: API Key,从 URL 查询参数中获取 apikey=xxx,或请求头中获取 X-API-KEY=xxx
|
||||
:return: 返回校验通过的 API Key
|
||||
"""
|
||||
return __verify_key(apikey, settings.API_TOKEN, "apikey")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证既有 bcrypt 哈希,并保留超长历史密码的截断语义。"""
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
_encode_bcrypt_password(
|
||||
plain_password, allow_legacy_truncation=True
|
||||
),
|
||||
hashed_password.encode("ascii"),
|
||||
)
|
||||
except (UnicodeEncodeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""使用 $2b$ 前缀和 cost 12 生成可持久化的 bcrypt 密码哈希。"""
|
||||
return bcrypt.hashpw(
|
||||
_encode_bcrypt_password(password),
|
||||
bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"),
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
|
||||
"""
|
||||
解密二进制数据
|
||||
"""
|
||||
fernet = Fernet(key)
|
||||
try:
|
||||
return fernet.decrypt(data)
|
||||
except Exception as e:
|
||||
logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
|
||||
def encrypt_message(message: str, key: bytes) -> str:
|
||||
"""
|
||||
使用给定的key对消息进行加密,并返回加密后的字符串
|
||||
"""
|
||||
f = Fernet(key)
|
||||
encrypted_message = f.encrypt(message.encode())
|
||||
return encrypted_message.decode()
|
||||
|
||||
|
||||
def hash_sha256(message: str) -> str:
|
||||
"""
|
||||
对字符串做hash运算
|
||||
"""
|
||||
return hashlib.sha256(message.encode()).hexdigest()
|
||||
|
||||
|
||||
def aes_decrypt(data: str, key: str) -> str:
|
||||
"""
|
||||
AES解密
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
data = base64.b64decode(data)
|
||||
iv = data[:16]
|
||||
encrypted = data[16:]
|
||||
# 使用AES-256-CBC解密
|
||||
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv)
|
||||
result = cipher.decrypt(encrypted)
|
||||
# 去除填充
|
||||
padding = result[-1]
|
||||
if padding < 1 or padding > AES.block_size:
|
||||
return ""
|
||||
result = result[:-padding]
|
||||
return result.decode('utf-8')
|
||||
|
||||
|
||||
def aes_encrypt(data: str, key: str) -> str:
|
||||
"""
|
||||
AES加密
|
||||
"""
|
||||
if not data:
|
||||
return ""
|
||||
# 使用AES-256-CBC加密
|
||||
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC)
|
||||
# 填充
|
||||
padding = AES.block_size - len(data) % AES.block_size
|
||||
data += chr(padding) * padding
|
||||
result = cipher.encrypt(data.encode('utf-8'))
|
||||
# 使用base64编码
|
||||
return base64.b64encode(cipher.iv + result).decode('utf-8')
|
||||
|
||||
|
||||
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
|
||||
"""
|
||||
NexusPHP加密
|
||||
"""
|
||||
# 生成16字节长的随机字符串
|
||||
iv = os.urandom(16)
|
||||
# 对向量进行 Base64 编码
|
||||
iv_base64 = base64.b64encode(iv)
|
||||
# 加密数据
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size))
|
||||
ciphertext_base64 = base64.b64encode(ciphertext)
|
||||
# 对向量的字符串表示进行签名
|
||||
mac = hmac.new(key, msg=iv_base64 + ciphertext_base64, digestmod=hashlib.sha256).hexdigest()
|
||||
# 构造 JSON 字符串
|
||||
json_str = json.dumps({
|
||||
'iv': iv_base64.decode(),
|
||||
'value': ciphertext_base64.decode(),
|
||||
'mac': mac,
|
||||
'tag': ''
|
||||
})
|
||||
|
||||
# 对 JSON 字符串进行 Base64 编码
|
||||
return base64.b64encode(json_str.encode()).decode()
|
||||
@@ -2,17 +2,12 @@ import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from app.schemas.token import Token as _SchemaToken
|
||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||
from app.application.security import access as security
|
||||
from app.application.security.token import create_access_token
|
||||
from app.runtime.config import settings
|
||||
from app.db.models.user import User
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.oper.user import UserOper
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
@@ -119,49 +114,128 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]:
|
||||
return AuthTicketStore().consume(ticket)
|
||||
|
||||
|
||||
def build_superuser_token_payload() -> _SchemaTokenPayload:
|
||||
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
|
||||
user = UserOper().get_by_name(settings.SUPERUSER)
|
||||
if not user or not user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户权限不足",
|
||||
)
|
||||
return _SchemaTokenPayload(
|
||||
sub=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
level=SitesHelper().auth_level,
|
||||
purpose="authentication",
|
||||
)
|
||||
class AuthUser(Protocol):
|
||||
"""认证服务需要的最小用户投影。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
avatar: Optional[str]
|
||||
permissions: Optional[dict]
|
||||
|
||||
|
||||
def build_token_response(user: User) -> _SchemaToken:
|
||||
"""
|
||||
使用系统统一逻辑构造登录 Token 响应。
|
||||
class AuthUserRepository(Protocol):
|
||||
"""认证服务的用户数据端口。"""
|
||||
|
||||
:param user: 已认证的本地用户
|
||||
:return: 标准 Token 响应
|
||||
"""
|
||||
level = SitesHelper().auth_level
|
||||
show_wizard = (
|
||||
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
|
||||
and not settings.ADVANCED_MODE
|
||||
)
|
||||
return _SchemaToken(
|
||||
access_token=security.create_access_token(
|
||||
userid=user.id,
|
||||
def get_by_name(self, name: str) -> Optional[AuthUser]:
|
||||
"""按用户名查询用户。"""
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[AuthUser]:
|
||||
"""按 ID 查询用户。"""
|
||||
|
||||
|
||||
class AuthPasskeyRepository(Protocol):
|
||||
"""认证提供方查询端口。"""
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
"""返回已启用的 PassKey。"""
|
||||
|
||||
|
||||
class AuthConfigRepository(Protocol):
|
||||
"""认证配置读取端口。"""
|
||||
|
||||
def get(self, key: Any) -> Any:
|
||||
"""读取配置值。"""
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""认证应用服务,编排用户、配置和 PassKey 端口。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
users: AuthUserRepository,
|
||||
config: AuthConfigRepository,
|
||||
passkeys: AuthPasskeyRepository,
|
||||
) -> None:
|
||||
"""注入认证所需的数据端口。"""
|
||||
self._users = users
|
||||
self._config = config
|
||||
self._passkeys = passkeys
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[AuthUser]:
|
||||
"""按 ID 查询本地用户。"""
|
||||
return self._users.get_by_id(user_id)
|
||||
|
||||
def has_passkey(self) -> bool:
|
||||
"""判断系统是否已有 PassKey。"""
|
||||
return bool(self._passkeys.list())
|
||||
|
||||
def build_superuser_token_payload(self) -> _SchemaTokenPayload:
|
||||
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
|
||||
user = self._users.get_by_name(settings.SUPERUSER)
|
||||
if not user or not user.is_superuser:
|
||||
raise PermissionError("用户权限不足")
|
||||
return _SchemaTokenPayload(
|
||||
sub=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
level=SitesHelper().auth_level,
|
||||
purpose="authentication",
|
||||
)
|
||||
|
||||
def build_token_response(self, user: AuthUser) -> _SchemaToken:
|
||||
"""使用统一逻辑构造登录 Token 响应。"""
|
||||
level = SitesHelper().auth_level
|
||||
show_wizard = (
|
||||
not self._config.get(SystemConfigKey.SetupWizardState)
|
||||
and not settings.ADVANCED_MODE
|
||||
)
|
||||
return _SchemaToken(
|
||||
access_token=create_access_token(
|
||||
userid=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
level=level,
|
||||
),
|
||||
token_type="bearer",
|
||||
super_user=user.is_superuser,
|
||||
user_id=user.id,
|
||||
user_name=user.name,
|
||||
avatar=user.avatar,
|
||||
level=level,
|
||||
),
|
||||
token_type="bearer",
|
||||
super_user=user.is_superuser,
|
||||
user_id=user.id,
|
||||
user_name=user.name,
|
||||
avatar=user.avatar,
|
||||
level=level,
|
||||
permissions=user.permissions or {},
|
||||
wizard=show_wizard,
|
||||
)
|
||||
permissions=user.permissions or {},
|
||||
wizard=show_wizard,
|
||||
)
|
||||
|
||||
|
||||
_configured_auth_service: AuthService | None = None
|
||||
|
||||
|
||||
def configure_auth_service(service: AuthService) -> None:
|
||||
"""由启动组合根登记认证应用服务。"""
|
||||
global _configured_auth_service
|
||||
_configured_auth_service = service
|
||||
|
||||
|
||||
def _get_auth_service() -> AuthService:
|
||||
"""返回启动阶段登记的认证应用服务。"""
|
||||
if _configured_auth_service is None:
|
||||
raise RuntimeError("认证服务尚未配置")
|
||||
return _configured_auth_service
|
||||
|
||||
|
||||
def get_configured_auth_service() -> AuthService:
|
||||
"""返回启动阶段登记的认证服务。"""
|
||||
return _get_auth_service()
|
||||
|
||||
|
||||
def build_superuser_token_payload() -> _SchemaTokenPayload:
|
||||
"""使用启动组合根注入的认证服务构造超级用户令牌载荷。"""
|
||||
return _get_auth_service().build_superuser_token_payload()
|
||||
|
||||
|
||||
def build_token_response(user: AuthUser) -> _SchemaToken:
|
||||
"""使用启动组合根注入的认证服务构造登录 Token 响应。"""
|
||||
return _get_auth_service().build_token_response(user)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""PassKey 认证凭证应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
|
||||
class PasskeyRepository(Protocol):
|
||||
"""PassKey 用例需要的最小同步数据端口。"""
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
"""列出全部启用凭证。"""
|
||||
|
||||
def list_by_user_id(self, user_id: int) -> list[Any]:
|
||||
"""列出指定用户凭证。"""
|
||||
|
||||
def get_by_credential_id(self, credential_id: str) -> Optional[Any]:
|
||||
"""按凭证 ID 查找凭证。"""
|
||||
|
||||
def create(self, payload: dict[str, Any]) -> Any:
|
||||
"""创建凭证。"""
|
||||
|
||||
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
|
||||
"""更新凭证使用计数。"""
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除用户凭证。"""
|
||||
|
||||
|
||||
class PasskeyService:
|
||||
"""编排 PassKey 凭证生命周期。"""
|
||||
|
||||
def __init__(self, repository: PasskeyRepository) -> None:
|
||||
"""注入 PassKey 数据端口。"""
|
||||
self._repository = repository
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
"""列出全部启用凭证。"""
|
||||
return self._repository.list()
|
||||
|
||||
def list_by_user_id(self, user_id: int) -> list[Any]:
|
||||
"""列出指定用户凭证。"""
|
||||
return self._repository.list_by_user_id(user_id)
|
||||
|
||||
def get_by_credential_id(self, credential_id: str) -> Optional[Any]:
|
||||
"""按凭证 ID 查找凭证。"""
|
||||
return self._repository.get_by_credential_id(credential_id)
|
||||
|
||||
def create(self, payload: dict[str, Any]) -> Any:
|
||||
"""创建凭证。"""
|
||||
return self._repository.create(payload)
|
||||
|
||||
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
|
||||
"""更新凭证使用计数。"""
|
||||
return self._repository.update_last_used(passkey, sign_count)
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除用户凭证。"""
|
||||
return self._repository.delete_by_id(passkey_id, user_id)
|
||||
|
||||
|
||||
_configured_passkey_service: PasskeyService | None = None
|
||||
|
||||
|
||||
def configure_passkey_service(service: PasskeyService) -> None:
|
||||
"""由启动组合根登记 PassKey 应用服务。"""
|
||||
global _configured_passkey_service
|
||||
_configured_passkey_service = service
|
||||
|
||||
|
||||
def get_configured_passkey_service() -> PasskeyService:
|
||||
"""返回启动阶段登记的 PassKey 应用服务。"""
|
||||
if _configured_passkey_service is None:
|
||||
raise RuntimeError("PassKey 服务尚未配置")
|
||||
return _configured_passkey_service
|
||||
@@ -0,0 +1,201 @@
|
||||
"""与传输框架无关的令牌、密码和对称加密能力。"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.token import TokenPayload
|
||||
|
||||
BCRYPT_PASSWORD_MAX_BYTES = 72
|
||||
BCRYPT_ROUNDS = 12
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
class PasswordTooLongError(ValueError):
|
||||
"""密码的 UTF-8 字节长度超过 bcrypt 可安全处理的上限。"""
|
||||
|
||||
|
||||
class TokenValidationError(ValueError):
|
||||
"""令牌缺失、签名无效或用途不符合调用方要求。"""
|
||||
|
||||
|
||||
def _encode_bcrypt_password(
|
||||
password: str,
|
||||
*,
|
||||
allow_legacy_truncation: bool = False,
|
||||
) -> bytes:
|
||||
"""编码 bcrypt 密码;仅验证既有哈希时允许按历史语义截断。"""
|
||||
password_bytes = password.encode("utf-8")
|
||||
if len(password_bytes) > BCRYPT_PASSWORD_MAX_BYTES:
|
||||
if allow_legacy_truncation:
|
||||
return password_bytes[:BCRYPT_PASSWORD_MAX_BYTES]
|
||||
raise PasswordTooLongError(
|
||||
f"密码 UTF-8 编码后不能超过 {BCRYPT_PASSWORD_MAX_BYTES} 字节"
|
||||
)
|
||||
return password_bytes
|
||||
|
||||
|
||||
def create_access_token(
|
||||
userid: Union[str, Any],
|
||||
username: str,
|
||||
super_user: Optional[bool] = False,
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
level: Optional[int] = 1,
|
||||
purpose: Optional[str] = "authentication",
|
||||
) -> str:
|
||||
"""创建带身份、权限等级和用途声明的 JWT 访问令牌。"""
|
||||
if purpose == "resource":
|
||||
default_expire = timedelta(
|
||||
seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
)
|
||||
secret_key = settings.RESOURCE_SECRET_KEY
|
||||
else:
|
||||
default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
secret_key = settings.SECRET_KEY
|
||||
|
||||
if expires_delta is not None:
|
||||
if expires_delta.total_seconds() <= 0:
|
||||
raise ValueError("过期时间必须为正数")
|
||||
expire = datetime.datetime.now(datetime.UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.datetime.now(datetime.UTC) + default_expire
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
payload = {
|
||||
"exp": expire,
|
||||
"iat": now,
|
||||
"sub": str(userid),
|
||||
"username": username,
|
||||
"super_user": super_user,
|
||||
"level": level,
|
||||
"purpose": purpose,
|
||||
}
|
||||
return jwt.encode(payload, secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(
|
||||
token: str | None,
|
||||
purpose: str = "authentication",
|
||||
) -> TokenPayload:
|
||||
"""校验 JWT 签名和用途并返回框架无关的令牌载荷。"""
|
||||
if not token:
|
||||
raise TokenValidationError(f"{purpose} token not found")
|
||||
secret_key = (
|
||||
settings.RESOURCE_SECRET_KEY
|
||||
if purpose == "resource"
|
||||
else settings.SECRET_KEY
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM])
|
||||
token_payload = TokenPayload(**payload)
|
||||
if token_payload.purpose != purpose:
|
||||
raise jwt.InvalidTokenError("令牌用途不匹配")
|
||||
return token_payload
|
||||
except (
|
||||
jwt.DecodeError,
|
||||
jwt.InvalidTokenError,
|
||||
jwt.ImmatureSignatureError,
|
||||
) as error:
|
||||
raise TokenValidationError("token校验不通过") from error
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证既有 bcrypt 哈希,并保留超长历史密码的截断语义。"""
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
_encode_bcrypt_password(
|
||||
plain_password,
|
||||
allow_legacy_truncation=True,
|
||||
),
|
||||
hashed_password.encode("ascii"),
|
||||
)
|
||||
except (UnicodeEncodeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""使用 ``$2b$`` 前缀和 cost 12 生成可持久化的 bcrypt 哈希。"""
|
||||
return bcrypt.hashpw(
|
||||
_encode_bcrypt_password(password),
|
||||
bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"),
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
|
||||
"""使用 Fernet 解密二进制数据,失败时记录诊断并返回空值。"""
|
||||
try:
|
||||
return Fernet(key).decrypt(data)
|
||||
except Exception as error:
|
||||
logger.error(f"解密失败:{str(error)} - {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
|
||||
def encrypt_message(message: str, key: bytes) -> str:
|
||||
"""使用 Fernet 加密文本并返回可传输字符串。"""
|
||||
return Fernet(key).encrypt(message.encode()).decode()
|
||||
|
||||
|
||||
def hash_sha256(message: str) -> str:
|
||||
"""返回文本的 SHA-256 十六进制摘要。"""
|
||||
return hashlib.sha256(message.encode()).hexdigest()
|
||||
|
||||
|
||||
def aes_decrypt(data: str, key: str) -> str:
|
||||
"""按历史 AES-256-CBC 合同解密 Base64 文本。"""
|
||||
if not data:
|
||||
return ""
|
||||
raw_data = base64.b64decode(data)
|
||||
iv = raw_data[:16]
|
||||
encrypted = raw_data[16:]
|
||||
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv)
|
||||
result = cipher.decrypt(encrypted)
|
||||
padding = result[-1]
|
||||
if padding < 1 or padding > AES.block_size:
|
||||
return ""
|
||||
return result[:-padding].decode("utf-8")
|
||||
|
||||
|
||||
def aes_encrypt(data: str, key: str) -> str:
|
||||
"""按历史 AES-256-CBC 合同加密文本并返回 Base64 字符串。"""
|
||||
if not data:
|
||||
return ""
|
||||
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC)
|
||||
padding = AES.block_size - len(data) % AES.block_size
|
||||
padded = data + chr(padding) * padding
|
||||
result = cipher.encrypt(padded.encode("utf-8"))
|
||||
return base64.b64encode(cipher.iv + result).decode("utf-8")
|
||||
|
||||
|
||||
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
|
||||
"""生成 NexusPHP 兼容的 AES-CBC 加密载荷。"""
|
||||
iv = os.urandom(16)
|
||||
iv_base64 = base64.b64encode(iv)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size))
|
||||
ciphertext_base64 = base64.b64encode(ciphertext)
|
||||
mac = hmac.new(
|
||||
key,
|
||||
msg=iv_base64 + ciphertext_base64,
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
payload = json.dumps({
|
||||
"iv": iv_base64.decode(),
|
||||
"value": ciphertext_base64.decode(),
|
||||
"mac": mac,
|
||||
"tag": "",
|
||||
})
|
||||
return base64.b64encode(payload.encode()).decode()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""用户管理用例。
|
||||
|
||||
该模块承接用户端点需要的异步用户操作。具体数据库访问由请求组合根注入,
|
||||
避免 API 层同时承担 HTTP 编排和 ORM 适配职责。
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
"""用户用例所需的最小异步数据端口。"""
|
||||
|
||||
async def async_list(self) -> list[Any]:
|
||||
"""返回全部用户。"""
|
||||
|
||||
async def async_get_by_name(self, name: str) -> Any | None:
|
||||
"""按用户名返回用户。"""
|
||||
|
||||
async def async_get_by_id(self, user_id: int) -> Any | None:
|
||||
"""按用户 ID 返回用户。"""
|
||||
|
||||
async def async_create(self, payload: dict[str, Any]) -> Any | None:
|
||||
"""创建用户并返回持久化对象。"""
|
||||
|
||||
async def async_update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
|
||||
"""更新用户并返回原用户对象。"""
|
||||
|
||||
async def async_delete(self, user_id: int) -> None:
|
||||
"""删除用户。"""
|
||||
|
||||
async def async_update_otp_by_name(self, name: str, otp: bool, secret: str) -> None:
|
||||
"""更新用户 OTP 状态。"""
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户管理应用服务。"""
|
||||
|
||||
def __init__(self, repository: UserRepository) -> None:
|
||||
"""创建用户服务。"""
|
||||
self._repository = repository
|
||||
|
||||
async def list(self) -> list[Any]:
|
||||
"""返回用户列表。"""
|
||||
return await self._repository.async_list()
|
||||
|
||||
async def get_by_name(self, name: str) -> Any | None:
|
||||
"""按用户名查询用户。"""
|
||||
return await self._repository.async_get_by_name(name)
|
||||
|
||||
async def get_by_id(self, user_id: int) -> Any | None:
|
||||
"""按用户 ID 查询用户。"""
|
||||
return await self._repository.async_get_by_id(user_id)
|
||||
|
||||
async def create(self, payload: dict[str, Any]) -> Any | None:
|
||||
"""创建用户。"""
|
||||
return await self._repository.async_create(payload)
|
||||
|
||||
async def update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
|
||||
"""更新用户。"""
|
||||
return await self._repository.async_update(user_id, payload)
|
||||
|
||||
async def delete(self, user_id: int) -> None:
|
||||
"""删除用户。"""
|
||||
await self._repository.async_delete(user_id)
|
||||
|
||||
async def update_otp(self, name: str, otp: bool, secret: str) -> None:
|
||||
"""更新用户 OTP 状态。"""
|
||||
await self._repository.async_update_otp_by_name(name, otp, secret)
|
||||
|
||||
|
||||
_configured_user_id_lookup: Callable[[int], Any | None] | None = None
|
||||
_configured_user_name_lookup: Callable[[str], Any | None] | None = None
|
||||
_configured_user_channel_lookup: Callable[..., str | None] | None = None
|
||||
|
||||
|
||||
def configure_user_lookups(
|
||||
by_id: Callable[[int], Any | None],
|
||||
by_name: Callable[[str], Any | None],
|
||||
by_channel: Callable[..., str | None],
|
||||
) -> None:
|
||||
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
|
||||
global _configured_user_id_lookup, _configured_user_name_lookup
|
||||
global _configured_user_channel_lookup
|
||||
_configured_user_id_lookup = by_id
|
||||
_configured_user_name_lookup = by_name
|
||||
_configured_user_channel_lookup = by_channel
|
||||
|
||||
|
||||
def get_configured_user_id_lookup() -> Callable[[int], Any | None]:
|
||||
"""返回启动阶段登记的按 ID 用户查询函数。"""
|
||||
if _configured_user_id_lookup is None:
|
||||
raise RuntimeError("按 ID 的用户查询能力尚未配置")
|
||||
return _configured_user_id_lookup
|
||||
|
||||
|
||||
def get_configured_user_name_lookup() -> Callable[[str], Any | None]:
|
||||
"""返回启动阶段登记的按用户名查询函数。"""
|
||||
if _configured_user_name_lookup is None:
|
||||
raise RuntimeError("按用户名的用户查询能力尚未配置")
|
||||
return _configured_user_name_lookup
|
||||
|
||||
|
||||
def get_configured_user_channel_lookup() -> Callable[..., str | None]:
|
||||
"""返回启动阶段登记的渠道身份到用户名查询函数。"""
|
||||
if _configured_user_channel_lookup is None:
|
||||
raise RuntimeError("渠道用户查询能力尚未配置")
|
||||
return _configured_user_channel_lookup
|
||||
@@ -0,0 +1,47 @@
|
||||
"""用户个性化配置应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class UserConfigurationRepository(Protocol):
|
||||
"""用户配置数据端口。"""
|
||||
|
||||
def get(self, username: str, key: str) -> Any:
|
||||
"""读取用户配置。"""
|
||||
|
||||
def set(self, username: str, key: str, value: Any) -> Any:
|
||||
"""写入用户配置。"""
|
||||
|
||||
|
||||
class UserConfigurationService:
|
||||
"""编排用户个性化配置读写。"""
|
||||
|
||||
def __init__(self, repository: UserConfigurationRepository) -> None:
|
||||
"""注入用户配置数据端口。"""
|
||||
self._repository = repository
|
||||
|
||||
def get(self, username: str, key: str) -> Any:
|
||||
"""读取用户配置。"""
|
||||
return self._repository.get(username=username, key=key)
|
||||
|
||||
def set(self, username: str, key: str, value: Any) -> Any:
|
||||
"""写入用户配置。"""
|
||||
return self._repository.set(username=username, key=key, value=value)
|
||||
|
||||
|
||||
_configured_user_configuration: UserConfigurationService | None = None
|
||||
|
||||
|
||||
def configure_user_configuration(service: UserConfigurationService) -> None:
|
||||
"""由启动组合根登记用户配置服务。"""
|
||||
global _configured_user_configuration
|
||||
_configured_user_configuration = service
|
||||
|
||||
|
||||
def get_configured_user_configuration() -> UserConfigurationService:
|
||||
"""返回启动阶段登记的用户配置服务。"""
|
||||
if _configured_user_configuration is None:
|
||||
raise RuntimeError("用户配置服务尚未配置")
|
||||
return _configured_user_configuration
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Servarr 兼容接口使用的订阅投影和数据用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServarrSubscription:
|
||||
"""隔离 Servarr 端点与订阅 ORM 模型的稳定投影。"""
|
||||
|
||||
id: int
|
||||
name: Optional[str]
|
||||
year: Optional[str]
|
||||
type: Optional[str]
|
||||
season: Optional[int]
|
||||
poster: Optional[str]
|
||||
media_source: Optional[str]
|
||||
media_id: Optional[str]
|
||||
|
||||
|
||||
class ServarrAsyncSubscriptionRepository(Protocol):
|
||||
"""Servarr 异步订阅用例需要的最小仓储端口。"""
|
||||
|
||||
async def async_list(self) -> list[Any]:
|
||||
"""读取全部订阅。"""
|
||||
...
|
||||
|
||||
async def async_get(self, subscribe_id: int) -> Optional[Any]:
|
||||
"""按主键读取订阅。"""
|
||||
...
|
||||
|
||||
async def async_list_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[Any]:
|
||||
"""按媒体身份读取订阅。"""
|
||||
...
|
||||
|
||||
async def async_exists(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: Optional[int] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[Any]:
|
||||
"""按媒体身份读取命中的订阅。"""
|
||||
...
|
||||
|
||||
async def async_delete(self, subscribe_id: int) -> None:
|
||||
"""按主键删除订阅。"""
|
||||
...
|
||||
|
||||
|
||||
class ServarrSyncSubscriptionRepository(Protocol):
|
||||
"""Servarr 同步 lookup 用例需要的最小仓储端口。"""
|
||||
|
||||
def list_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[Any]:
|
||||
"""按媒体身份读取订阅。"""
|
||||
...
|
||||
|
||||
|
||||
class ServarrSubscriptionService:
|
||||
"""提供 Servarr 路由所需的订阅查询、查重和删除能力。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
async_repository: ServarrAsyncSubscriptionRepository,
|
||||
sync_repository: ServarrSyncSubscriptionRepository,
|
||||
) -> None:
|
||||
"""保存请求级同步和异步订阅仓储。"""
|
||||
self._async_repository = async_repository
|
||||
self._sync_repository = sync_repository
|
||||
|
||||
async def list(self) -> list[ServarrSubscription]:
|
||||
"""读取全部订阅并转换为脱离 ORM 会话的投影。"""
|
||||
return [self._project(record) for record in await self._async_repository.async_list()]
|
||||
|
||||
async def get(self, subscribe_id: int) -> Optional[ServarrSubscription]:
|
||||
"""按主键读取订阅投影。"""
|
||||
record = await self._async_repository.async_get(subscribe_id)
|
||||
return self._project(record) if record else None
|
||||
|
||||
async def list_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
) -> list[ServarrSubscription]:
|
||||
"""异步按媒体身份读取订阅投影。"""
|
||||
records = await self._async_repository.async_list_by_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
return [self._project(record) for record in records]
|
||||
|
||||
def list_by_media_identity_sync(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
) -> list[ServarrSubscription]:
|
||||
"""同步按媒体身份读取订阅投影。"""
|
||||
records = self._sync_repository.list_by_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
return [self._project(record) for record in records]
|
||||
|
||||
async def exists(
|
||||
self,
|
||||
*,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""判断指定媒体身份和季是否已有订阅。"""
|
||||
record = await self._async_repository.async_exists(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
)
|
||||
return record is not None
|
||||
|
||||
async def delete(self, subscribe_id: int) -> bool:
|
||||
"""删除存在的订阅并报告是否实际命中。"""
|
||||
if not await self._async_repository.async_get(subscribe_id):
|
||||
return False
|
||||
await self._async_repository.async_delete(subscribe_id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _project(record: Any) -> ServarrSubscription:
|
||||
"""从数据库记录复制 Servarr 路由所需的最小字段。"""
|
||||
return ServarrSubscription(
|
||||
id=record.id,
|
||||
name=getattr(record, "name", None),
|
||||
year=getattr(record, "year", None),
|
||||
type=getattr(record, "type", None),
|
||||
season=getattr(record, "season", None),
|
||||
poster=getattr(record, "poster", None),
|
||||
media_source=getattr(record, "media_source", None),
|
||||
media_id=getattr(record, "media_id", None),
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""下载器、媒体服务器和通知服务的应用层目录端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar
|
||||
|
||||
from app.schemas.system import ServiceInfo
|
||||
from app.schemas.types import ModuleType, SystemConfigKey
|
||||
|
||||
TConf = TypeVar("TConf")
|
||||
ServiceConfigLoader = Callable[[SystemConfigKey, Type[Any]], list[Any]]
|
||||
RunningModuleLoader = Callable[[ModuleType], list[Any]]
|
||||
|
||||
|
||||
def _unconfigured_configs(
|
||||
_config_key: SystemConfigKey,
|
||||
_conf_type: Type[Any],
|
||||
) -> list[Any]:
|
||||
"""拒绝在启动组合根装配前隐式读取服务配置。"""
|
||||
raise RuntimeError("服务配置目录尚未由启动组合根配置")
|
||||
|
||||
|
||||
def _unconfigured_modules(_module_type: ModuleType) -> list[Any]:
|
||||
"""拒绝在启动组合根装配前隐式抓取模块管理器。"""
|
||||
raise RuntimeError("运行模块目录尚未由启动组合根配置")
|
||||
|
||||
|
||||
_config_loader: ServiceConfigLoader = _unconfigured_configs
|
||||
_module_loader: RunningModuleLoader = _unconfigured_modules
|
||||
|
||||
|
||||
def configure_service_directory(
|
||||
*,
|
||||
configs: ServiceConfigLoader,
|
||||
modules: RunningModuleLoader,
|
||||
) -> None:
|
||||
"""由启动组合根注入服务配置和运行模块枚举端口。"""
|
||||
global _config_loader, _module_loader
|
||||
_config_loader = configs
|
||||
_module_loader = modules
|
||||
|
||||
|
||||
class ServiceBaseHelper(Generic[TConf]):
|
||||
"""通过应用端口查询服务配置和对应运行实例。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_key: SystemConfigKey,
|
||||
conf_type: Type[TConf],
|
||||
module_type: ModuleType,
|
||||
) -> None:
|
||||
"""绑定配置类型和模块能力类型,不抓取具体 Runtime 管理器。"""
|
||||
self.config_key = config_key
|
||||
self.conf_type = conf_type
|
||||
self.module_type = module_type
|
||||
|
||||
def get_configs(self, include_disabled: bool = False) -> Dict[str, TConf]:
|
||||
"""返回按名称索引的有效服务配置。"""
|
||||
configs = _config_loader(self.config_key, self.conf_type)
|
||||
return {
|
||||
config.name: config
|
||||
for config in configs
|
||||
if config.name
|
||||
and config.type
|
||||
and (config.enabled or include_disabled)
|
||||
}
|
||||
|
||||
def get_config(self, name: str) -> Optional[TConf]:
|
||||
"""按名称返回单个启用服务配置。"""
|
||||
return self.get_configs().get(name) if name else None
|
||||
|
||||
def iterate_module_instances(self) -> Iterator[ServiceInfo]:
|
||||
"""迭代当前类型所有运行模块实例及其配置投影。"""
|
||||
configs = self.get_configs()
|
||||
for module in _module_loader(self.module_type):
|
||||
if not module:
|
||||
continue
|
||||
instances = module.get_instances()
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
for name, instance in instances.items():
|
||||
if not instance:
|
||||
continue
|
||||
config = configs.get(name)
|
||||
yield ServiceInfo(
|
||||
name=name,
|
||||
instance=instance,
|
||||
module=module,
|
||||
type=config.type if config else None,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def get_services(
|
||||
self,
|
||||
type_filter: Optional[str] = None,
|
||||
name_filters: Optional[List[str]] = None,
|
||||
) -> Dict[str, ServiceInfo]:
|
||||
"""按服务类型和名称集合过滤运行实例。"""
|
||||
names = set(name_filters) if name_filters else None
|
||||
return {
|
||||
service.name: service
|
||||
for service in self.iterate_module_instances()
|
||||
if service.config
|
||||
and (type_filter is None or service.type == type_filter)
|
||||
and (names is None or service.name in names)
|
||||
}
|
||||
|
||||
def get_service(
|
||||
self,
|
||||
name: str,
|
||||
type_filter: Optional[str] = None,
|
||||
) -> Optional[ServiceInfo]:
|
||||
"""按名称和可选类型返回单个运行服务。"""
|
||||
if not name:
|
||||
return None
|
||||
for service in self.iterate_module_instances():
|
||||
if (
|
||||
service.name == name
|
||||
and service.config
|
||||
and (type_filter is None or service.type == type_filter)
|
||||
):
|
||||
return service
|
||||
return None
|
||||
@@ -0,0 +1,65 @@
|
||||
"""站点访问统计写入应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
|
||||
class SiteHealthRepository(Protocol):
|
||||
"""站点健康统计所需的最小写端口。"""
|
||||
|
||||
def success(self, domain: str, seconds: Optional[int] = None) -> Any:
|
||||
"""记录站点访问成功。"""
|
||||
...
|
||||
|
||||
def fail(self, domain: str) -> Any:
|
||||
"""记录站点访问失败。"""
|
||||
...
|
||||
|
||||
async def async_success(self, domain: str, seconds: Optional[int] = None) -> Any:
|
||||
"""异步记录站点访问成功。"""
|
||||
...
|
||||
|
||||
async def async_fail(self, domain: str) -> Any:
|
||||
"""异步记录站点访问失败。"""
|
||||
...
|
||||
|
||||
|
||||
class SiteHealthService:
|
||||
"""集中承接索引模块的站点健康统计写操作。"""
|
||||
|
||||
def __init__(self, repository: SiteHealthRepository) -> None:
|
||||
"""保存站点统计写端口。"""
|
||||
self._repository = repository
|
||||
|
||||
def success(self, domain: str, seconds: Optional[int] = None) -> Any:
|
||||
"""记录同步站点访问成功。"""
|
||||
return self._repository.success(domain, seconds)
|
||||
|
||||
def fail(self, domain: str) -> Any:
|
||||
"""记录同步站点访问失败。"""
|
||||
return self._repository.fail(domain)
|
||||
|
||||
async def async_success(self, domain: str, seconds: Optional[int] = None) -> Any:
|
||||
"""记录异步站点访问成功。"""
|
||||
return await self._repository.async_success(domain, seconds)
|
||||
|
||||
async def async_fail(self, domain: str) -> Any:
|
||||
"""记录异步站点访问失败。"""
|
||||
return await self._repository.async_fail(domain)
|
||||
|
||||
|
||||
_configured_site_health_service: SiteHealthService | None = None
|
||||
|
||||
|
||||
def configure_site_health_service(service: SiteHealthService) -> None:
|
||||
"""由启动组合根登记站点健康统计服务。"""
|
||||
global _configured_site_health_service
|
||||
_configured_site_health_service = service
|
||||
|
||||
|
||||
def get_configured_site_health_service() -> SiteHealthService:
|
||||
"""返回启动阶段登记的站点健康统计服务。"""
|
||||
if _configured_site_health_service is None:
|
||||
raise RuntimeError("站点健康统计服务尚未配置")
|
||||
return _configured_site_health_service
|
||||
@@ -41,6 +41,10 @@ class SiteMutationRepository(Protocol):
|
||||
"""暂存一组站点优先级变更。"""
|
||||
...
|
||||
|
||||
async def stage_reset(self) -> None:
|
||||
"""暂存清空全部站点。"""
|
||||
...
|
||||
|
||||
|
||||
SiteIndexerLoader = Callable[[str], Awaitable[Optional[dict]]]
|
||||
SiteEventPublisher = Callable[[dict], Awaitable[None]]
|
||||
@@ -133,6 +137,13 @@ class SiteMutationCommand:
|
||||
await self._publish_deleted({"site_id": site_id})
|
||||
return SiteMutationResult(True)
|
||||
|
||||
async def reset(self) -> SiteMutationResult:
|
||||
"""清空全部站点,并在提交后发布通配站点删除事件。"""
|
||||
await self._repository.stage_reset()
|
||||
await self._commit()
|
||||
await self._publish_deleted({"site_id": "*"})
|
||||
return SiteMutationResult(True)
|
||||
|
||||
async def _commit(self) -> None:
|
||||
"""提交当前站点事务,失败时回滚并保留原始异常。"""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""站点及站点运行数据的只读应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from app.schemas.site import SiteIconData, SiteStatistic, SiteUserData
|
||||
from app.schemas.workflow import Site
|
||||
|
||||
|
||||
class SiteQueryRepository(Protocol):
|
||||
"""站点查询用例需要的最小持久化端口。"""
|
||||
|
||||
async def async_list_order_by_pri(self) -> list[Any]:
|
||||
"""按优先级读取站点。"""
|
||||
...
|
||||
|
||||
async def async_list(self) -> list[Any]:
|
||||
"""读取全部站点。"""
|
||||
...
|
||||
|
||||
async def async_get(self, site_id: int) -> Optional[Any]:
|
||||
"""按 ID 读取站点。"""
|
||||
...
|
||||
|
||||
async def async_get_by_domain(self, domain: str) -> Optional[Any]:
|
||||
"""按域名读取站点。"""
|
||||
...
|
||||
|
||||
async def async_get_userdata_latest(self) -> list[Any]:
|
||||
"""读取各站点最新用户数据。"""
|
||||
...
|
||||
|
||||
async def async_get_userdata_by_domain(
|
||||
self,
|
||||
domain: str,
|
||||
workdate: Optional[str] = None,
|
||||
) -> list[Any]:
|
||||
"""读取站点用户数据。"""
|
||||
...
|
||||
|
||||
async def async_get_icon_by_domain(self, domain: str) -> Optional[Any]:
|
||||
"""按域名读取站点图标。"""
|
||||
...
|
||||
|
||||
async def async_get_statistic_by_domain(self, domain: str) -> Optional[Any]:
|
||||
"""按域名读取站点统计。"""
|
||||
...
|
||||
|
||||
async def async_list_statistics(self) -> list[Any]:
|
||||
"""读取全部站点统计。"""
|
||||
...
|
||||
|
||||
def get(self, site_id: int) -> Optional[Any]:
|
||||
"""同步按 ID 读取站点。"""
|
||||
...
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
"""同步读取全部站点。"""
|
||||
...
|
||||
|
||||
def list_order_by_pri(self) -> list[Any]:
|
||||
"""同步按优先级读取站点。"""
|
||||
...
|
||||
|
||||
def get_userdata_latest(self) -> list[Any]:
|
||||
"""同步读取各站点最新用户数据。"""
|
||||
...
|
||||
|
||||
|
||||
class SiteQueryService:
|
||||
"""把站点 ORM 投影为 API/Chain 可复用的稳定 DTO。"""
|
||||
|
||||
def __init__(self, repository: SiteQueryRepository) -> None:
|
||||
"""保存站点查询仓储端口。"""
|
||||
self._repository = repository
|
||||
|
||||
async def list_ordered(self) -> list[Site]:
|
||||
"""按站点优先级返回配置 DTO。"""
|
||||
return [Site.model_validate(item) for item in await self._repository.async_list_order_by_pri()]
|
||||
|
||||
async def list(self) -> list[Site]:
|
||||
"""返回全部站点配置 DTO。"""
|
||||
return [Site.model_validate(item) for item in await self._repository.async_list()]
|
||||
|
||||
async def get(self, site_id: int) -> Optional[Site]:
|
||||
"""按 ID 返回站点配置 DTO。"""
|
||||
item = await self._repository.async_get(site_id)
|
||||
return Site.model_validate(item) if item else None
|
||||
|
||||
def get_sync(self, site_id: int) -> Optional[Site]:
|
||||
"""同步按 ID 返回站点配置 DTO。"""
|
||||
item = self._repository.get(site_id)
|
||||
return Site.model_validate(item) if item else None
|
||||
|
||||
def list_sync(self) -> list[Site]:
|
||||
"""同步返回全部站点配置 DTO。"""
|
||||
return [
|
||||
Site.model_validate(item)
|
||||
for item in self._repository.list_order_by_pri()
|
||||
]
|
||||
|
||||
async def get_by_domain(self, domain: str) -> Optional[Site]:
|
||||
"""按域名返回站点配置 DTO。"""
|
||||
item = await self._repository.async_get_by_domain(domain)
|
||||
return Site.model_validate(item) if item else None
|
||||
|
||||
async def userdata_latest(self) -> list[SiteUserData]:
|
||||
"""返回各站点最新用户数据 DTO。"""
|
||||
return [
|
||||
SiteUserData.model_validate(item)
|
||||
for item in await self._repository.async_get_userdata_latest()
|
||||
]
|
||||
|
||||
async def userdata(
|
||||
self,
|
||||
domain: str,
|
||||
workdate: Optional[str] = None,
|
||||
) -> list[SiteUserData]:
|
||||
"""返回指定站点用户数据 DTO。"""
|
||||
return [
|
||||
SiteUserData.model_validate(item)
|
||||
for item in await self._repository.async_get_userdata_by_domain(
|
||||
domain,
|
||||
workdate,
|
||||
)
|
||||
]
|
||||
|
||||
async def icon(self, domain: str) -> Optional[SiteIconData]:
|
||||
"""返回站点图标 DTO。"""
|
||||
item = await self._repository.async_get_icon_by_domain(domain)
|
||||
if not item:
|
||||
return None
|
||||
return SiteIconData(
|
||||
icon=item.base64 if item.base64 else item.url,
|
||||
)
|
||||
|
||||
async def statistic(self, domain: str) -> SiteStatistic:
|
||||
"""返回指定站点统计 DTO,未命中时返回空统计。"""
|
||||
item = await self._repository.async_get_statistic_by_domain(domain)
|
||||
return SiteStatistic.model_validate(item) if item else SiteStatistic(domain=domain)
|
||||
|
||||
async def statistics(self) -> list[SiteStatistic]:
|
||||
"""返回全部站点统计 DTO。"""
|
||||
return [
|
||||
SiteStatistic.model_validate(item)
|
||||
for item in await self._repository.async_list_statistics()
|
||||
]
|
||||
|
||||
def userdata_latest_sync(self) -> list[SiteUserData]:
|
||||
"""同步返回各站点最新用户数据 DTO。"""
|
||||
return [
|
||||
SiteUserData.model_validate(item)
|
||||
for item in self._repository.get_userdata_latest()
|
||||
]
|
||||
|
||||
|
||||
_configured_site_query_service: SiteQueryService | None = None
|
||||
|
||||
|
||||
def configure_site_query_service(service: SiteQueryService) -> None:
|
||||
"""由启动组合根登记站点查询服务。"""
|
||||
global _configured_site_query_service
|
||||
_configured_site_query_service = service
|
||||
|
||||
|
||||
def get_configured_site_query_service() -> SiteQueryService:
|
||||
"""返回启动阶段登记的站点查询服务。"""
|
||||
if _configured_site_query_service is None:
|
||||
raise RuntimeError("站点查询服务尚未配置")
|
||||
return _configured_site_query_service
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from app.schemas.system import StorageConf as _SchemaStorageConf
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class StorageHelper:
|
||||
"""
|
||||
获取所有存储设置
|
||||
"""
|
||||
storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages)
|
||||
storage_confs: List[dict] = get_configured_system_config().get(SystemConfigKey.Storages)
|
||||
if not storage_confs:
|
||||
return []
|
||||
return [_SchemaStorageConf(**s) for s in storage_confs]
|
||||
@@ -47,7 +47,7 @@ class StorageHelper:
|
||||
if s.type == storage:
|
||||
s.config = conf
|
||||
break
|
||||
SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])
|
||||
get_configured_system_config().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])
|
||||
|
||||
def add_storage(self, storage: str, name: str, conf: dict):
|
||||
"""
|
||||
@@ -68,7 +68,7 @@ class StorageHelper:
|
||||
name=name,
|
||||
config=conf
|
||||
))
|
||||
SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])
|
||||
get_configured_system_config().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])
|
||||
|
||||
def reset_storage(self, storage: str):
|
||||
"""
|
||||
@@ -79,4 +79,4 @@ class StorageHelper:
|
||||
if s.type == storage:
|
||||
s.config = {}
|
||||
break
|
||||
SystemConfigOper().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])
|
||||
get_configured_system_config().set(SystemConfigKey.Storages, [s.model_dump() for s in storagies])
|
||||
|
||||
@@ -14,9 +14,9 @@ app/application/history.py 里整理历史的写入路径同构。
|
||||
张表。同步与异步是两份逐字复制的实现,改一条漏一条就是真实缺陷,故翻译与身份构造由
|
||||
下方 _translate 单点承担,两条链路只在「怎么查、怎么写」上分叉。
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
from collections.abc import Callable
|
||||
from typing import Optional, Protocol, Tuple
|
||||
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||
@@ -26,6 +26,39 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||
INCOMPLETE_IDENTITY = (0, "媒体身份不完整")
|
||||
|
||||
|
||||
class SubscribeWriter(Protocol):
|
||||
"""订阅写入应用服务使用的数据端口。"""
|
||||
|
||||
def add(self, identity: dict, payload: dict, username: Optional[str] = None) -> Tuple[int, str]:
|
||||
"""同步新增订阅。"""
|
||||
|
||||
async def async_add(
|
||||
self,
|
||||
identity: dict,
|
||||
payload: dict,
|
||||
username: Optional[str] = None,
|
||||
) -> Tuple[int, str]:
|
||||
"""异步新增订阅。"""
|
||||
|
||||
|
||||
_configured_subscribe_writer: Callable[[], SubscribeWriter] | None = None
|
||||
|
||||
|
||||
def configure_subscribe_writer(provider: Callable[[], SubscribeWriter]) -> None:
|
||||
"""由启动组合根登记订阅写入端口提供器。"""
|
||||
global _configured_subscribe_writer
|
||||
_configured_subscribe_writer = provider
|
||||
|
||||
|
||||
def _get_subscribe_writer(writer: Optional[SubscribeWriter]) -> SubscribeWriter:
|
||||
"""获取显式传入或启动组合根登记的订阅写入端口。"""
|
||||
if writer is not None:
|
||||
return writer
|
||||
if _configured_subscribe_writer is None:
|
||||
raise RuntimeError("订阅写入端口尚未配置")
|
||||
return _configured_subscribe_writer()
|
||||
|
||||
|
||||
def _music_entity(mediainfo: MediaInfo | MusicInfo) -> Optional[str]:
|
||||
"""
|
||||
取音乐实体类型;非音乐媒体一律为空。
|
||||
@@ -87,7 +120,7 @@ def _translate(mediainfo: MediaInfo | MusicInfo,
|
||||
|
||||
|
||||
def add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
||||
subscribe_oper: Optional[SubscribeOper] = None,
|
||||
subscribe_oper: Optional[SubscribeWriter] = None,
|
||||
**kwargs) -> Tuple[int, str]:
|
||||
"""
|
||||
新增订阅。
|
||||
@@ -100,12 +133,12 @@ def add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
||||
if translated is None:
|
||||
return INCOMPLETE_IDENTITY
|
||||
identity, payload, username = translated
|
||||
oper = subscribe_oper or SubscribeOper()
|
||||
oper = _get_subscribe_writer(subscribe_oper)
|
||||
return oper.add(identity=identity, payload=payload, username=username)
|
||||
|
||||
|
||||
async def async_add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
||||
subscribe_oper: Optional[SubscribeOper] = None,
|
||||
subscribe_oper: Optional[SubscribeWriter] = None,
|
||||
**kwargs) -> Tuple[int, str]:
|
||||
"""
|
||||
异步新增订阅。
|
||||
@@ -118,5 +151,5 @@ async def async_add_subscribe(mediainfo: MediaInfo | MusicInfo,
|
||||
if translated is None:
|
||||
return INCOMPLETE_IDENTITY
|
||||
identity, payload, username = translated
|
||||
oper = subscribe_oper or SubscribeOper()
|
||||
oper = _get_subscribe_writer(subscribe_oper)
|
||||
return await oper.async_add(identity=identity, payload=payload, username=username)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""订阅写操作用例及其数据端口。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SubscriptionMutationRepository(Protocol):
|
||||
"""订阅写用例需要的异步数据端口。"""
|
||||
|
||||
async def async_get(self, subscribe_id: int) -> Any | None:
|
||||
"""按 ID 获取订阅。"""
|
||||
|
||||
async def async_update(self, subscribe_id: int, payload: dict[str, Any]) -> Any | None:
|
||||
"""更新订阅。"""
|
||||
|
||||
def get(self, subscribe_id: int) -> Any | None:
|
||||
"""同步按 ID 获取订阅。"""
|
||||
|
||||
|
||||
class SubscriptionHistoryMutationRepository(Protocol):
|
||||
"""订阅历史删除用例需要的最小数据端口。"""
|
||||
|
||||
async def async_get(self, history_id: int) -> Any | None:
|
||||
"""按 ID 获取订阅历史。"""
|
||||
|
||||
async def async_delete(self, history_id: int) -> None:
|
||||
"""删除订阅历史。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionActor:
|
||||
"""订阅写操作的权限主体。"""
|
||||
|
||||
name: str
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionMutation:
|
||||
"""一次订阅变更前后的稳定快照。"""
|
||||
|
||||
old: dict[str, Any]
|
||||
new: dict[str, Any]
|
||||
|
||||
|
||||
class SubscriptionMutationService:
|
||||
"""编排订阅访问控制、更新和历史删除。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscriptionMutationRepository,
|
||||
history_repository: SubscriptionHistoryMutationRepository | None = None,
|
||||
) -> None:
|
||||
"""注入订阅和订阅历史数据端口。"""
|
||||
self._repository = repository
|
||||
self._history_repository = history_repository
|
||||
|
||||
async def get_accessible(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> Any | None:
|
||||
"""读取当前主体可访问的订阅。"""
|
||||
subscribe = await self._repository.async_get(subscribe_id)
|
||||
return subscribe if self.can_access(subscribe, actor) else None
|
||||
|
||||
def get_accessible_sync(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> Any | None:
|
||||
"""同步读取当前主体可访问的订阅。"""
|
||||
subscribe = self._repository.get(subscribe_id)
|
||||
return subscribe if self.can_access(subscribe, actor) else None
|
||||
|
||||
async def update(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
actor: SubscriptionActor,
|
||||
existing: Any | None = None,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""更新当前主体可访问的订阅并返回前后快照。"""
|
||||
subscribe = existing or await self.get_accessible(subscribe_id, actor)
|
||||
if subscribe and not self.can_access(subscribe, actor):
|
||||
return None
|
||||
if not subscribe:
|
||||
return None
|
||||
old = subscribe.to_dict()
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
state: str,
|
||||
actor: SubscriptionActor,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""更新订阅状态并返回前后快照。"""
|
||||
return await self.update(subscribe_id, {"state": state}, actor)
|
||||
|
||||
async def reset(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""重置订阅进度和手工集数标记。"""
|
||||
subscribe = await self.get_accessible(subscribe_id, actor)
|
||||
if not subscribe:
|
||||
return None
|
||||
payload = {
|
||||
"note": [],
|
||||
"lack_episode": subscribe.total_episode,
|
||||
"current_priority": None,
|
||||
"current_audio_format": None,
|
||||
"current_bitrate": None,
|
||||
"current_bit_depth": None,
|
||||
"current_sample_rate": None,
|
||||
"episode_priority": {},
|
||||
"manual_total_episode": 0,
|
||||
"state": "R",
|
||||
}
|
||||
old = subscribe.to_dict()
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
|
||||
async def delete_history(
|
||||
self,
|
||||
history_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> bool:
|
||||
"""删除当前主体可访问的订阅历史。"""
|
||||
if self._history_repository is None:
|
||||
raise RuntimeError("订阅历史数据端口未配置")
|
||||
history = await self._history_repository.async_get(history_id)
|
||||
if not self.can_access(history, actor):
|
||||
return False
|
||||
await self._history_repository.async_delete(history_id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def can_access(subscribe: Any, actor: SubscriptionActor) -> bool:
|
||||
"""判断主体是否可访问订阅或订阅历史。"""
|
||||
if not subscribe:
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
username = getattr(subscribe, "username", None)
|
||||
return bool(username) and username == actor.name
|
||||
@@ -8,6 +8,7 @@ from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.workflow import Subscribe as SubscribeView
|
||||
|
||||
|
||||
class SubscriptionQueryRepository(Protocol):
|
||||
@@ -26,6 +27,54 @@ class SubscriptionQueryRepository(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class AsyncSubscriptionQueryRepository(Protocol):
|
||||
"""公开订阅查询所需的异步持久化端口。"""
|
||||
|
||||
async def async_list(self) -> list[Any]:
|
||||
"""读取全部订阅。"""
|
||||
...
|
||||
|
||||
async def async_list_by_username(self, username: str) -> list[Any]:
|
||||
"""读取指定用户订阅。"""
|
||||
...
|
||||
|
||||
async def async_get(self, subscribe_id: int) -> Optional[Any]:
|
||||
"""按 ID 读取订阅。"""
|
||||
...
|
||||
|
||||
async def async_list_by_media_identity(
|
||||
self,
|
||||
media_source: Any,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[Any]:
|
||||
"""按规范媒体身份读取订阅。"""
|
||||
...
|
||||
|
||||
|
||||
class AsyncSubscriptionHistoryQueryRepository(Protocol):
|
||||
"""订阅历史公开查询所需的异步持久化端口。"""
|
||||
|
||||
async def async_list_by_type(
|
||||
self,
|
||||
mtype: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[Any]:
|
||||
"""按媒体类型分页读取订阅历史。"""
|
||||
...
|
||||
|
||||
async def async_list_by_type_and_username(
|
||||
self,
|
||||
mtype: str,
|
||||
username: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[Any]:
|
||||
"""按媒体类型和用户分页读取订阅历史。"""
|
||||
...
|
||||
|
||||
|
||||
class SubscriptionQueryService:
|
||||
"""封装不修改订阅状态的三个公开查询用例。"""
|
||||
|
||||
@@ -37,9 +86,102 @@ class SubscriptionQueryService:
|
||||
"music_type",
|
||||
}
|
||||
|
||||
def __init__(self, repository: SubscriptionQueryRepository) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscriptionQueryRepository,
|
||||
*,
|
||||
async_repository: Optional[AsyncSubscriptionQueryRepository] = None,
|
||||
history_repository: Optional[AsyncSubscriptionHistoryQueryRepository] = None,
|
||||
) -> None:
|
||||
"""保存订阅查询仓储端口。"""
|
||||
self._repository = repository
|
||||
self._async_repository = async_repository
|
||||
self._history_repository = history_repository
|
||||
|
||||
async def list_public(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
) -> list[SubscribeView]:
|
||||
"""读取公开订阅列表并转换为稳定 DTO。"""
|
||||
if self._async_repository is None:
|
||||
raise RuntimeError("异步订阅查询端口未注册")
|
||||
if username:
|
||||
records = await self._async_repository.async_list_by_username(
|
||||
username=username
|
||||
)
|
||||
else:
|
||||
records = await self._async_repository.async_list()
|
||||
return [SubscribeView.model_validate(record) for record in records]
|
||||
|
||||
async def get_public(self, subscribe_id: int) -> Optional[SubscribeView]:
|
||||
"""按 ID 读取订阅 DTO。"""
|
||||
if self._async_repository is None:
|
||||
raise RuntimeError("异步订阅查询端口未注册")
|
||||
record = await self._async_repository.async_get(subscribe_id)
|
||||
return SubscribeView.model_validate(record) if record else None
|
||||
|
||||
async def list_by_media_identity(
|
||||
self,
|
||||
media_source: Any,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[SubscribeView]:
|
||||
"""按媒体身份读取订阅 DTO,并兼容旧音乐记录。"""
|
||||
if self._async_repository is None:
|
||||
raise RuntimeError("异步订阅查询端口未注册")
|
||||
records = await self._async_repository.async_list_by_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=music_type,
|
||||
)
|
||||
return [
|
||||
SubscribeView.model_validate(record)
|
||||
for record in records
|
||||
if self._matches_music_type(record, music_type)
|
||||
]
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
mtype: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
username: Optional[str] = None,
|
||||
) -> list[SubscribeView]:
|
||||
"""分页读取订阅历史 DTO。"""
|
||||
if self._history_repository is None:
|
||||
raise RuntimeError("订阅历史查询端口未注册")
|
||||
if username:
|
||||
records = await self._history_repository.async_list_by_type_and_username(
|
||||
mtype,
|
||||
username,
|
||||
page,
|
||||
count,
|
||||
)
|
||||
else:
|
||||
records = await self._history_repository.async_list_by_type(
|
||||
mtype,
|
||||
page,
|
||||
count,
|
||||
)
|
||||
result = []
|
||||
for record in records:
|
||||
item = SubscribeView.model_validate(record)
|
||||
if item.type == MediaType.TV.value:
|
||||
item.total_episode = 0
|
||||
item.lack_episode = 0
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _matches_music_type(record: Any, music_type: Optional[str]) -> bool:
|
||||
"""把迁移前未标注音乐类型的记录兼容为单曲。"""
|
||||
if not music_type:
|
||||
return True
|
||||
value = getattr(record, "music_type", None)
|
||||
return value == music_type or (
|
||||
music_type == "recording" and value is None
|
||||
)
|
||||
|
||||
def exists(
|
||||
self,
|
||||
|
||||
@@ -13,8 +13,8 @@ from app.domain.context import Context, TorrentInfo, MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import audio_quality_tier, normalize_audio_format, parse_audio_quality
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.db.oper.site import SiteOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.application.site.query import get_configured_site_query_service
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from app.adapters.network.http import RequestUtils
|
||||
@@ -26,6 +26,27 @@ from app.foundation.crypto import HashUtils
|
||||
|
||||
_SIZE_UNIT = 1024 * 1024
|
||||
|
||||
# 站点首页、RSS 与音乐独立缓存的稳定键名;迁移脚本也复用这一事实来源。
|
||||
_TORRENT_CACHE_KEYS = (
|
||||
"__torrents_cache__",
|
||||
"__rss_cache__",
|
||||
"__torrents_music_cache__",
|
||||
"__rss_music_cache__",
|
||||
)
|
||||
|
||||
|
||||
def clear_torrent_cache(cache_backend: Optional[Any] = None) -> None:
|
||||
"""清理站点首页、RSS 及音乐资源缓存,不依赖 Chain 运行上下文。
|
||||
|
||||
数据库迁移可能发生在生命周期组件装配之前,不能为了清理缓存构造
|
||||
``TorrentsChain`` 并隐式拉起插件、模块和消息依赖,因此这里直接使用缓存端口。
|
||||
|
||||
:param cache_backend: 可选缓存后端;未传入时使用当前宿主配置的文件缓存后端。
|
||||
"""
|
||||
backend = cache_backend or FileCache()
|
||||
for cache_key in _TORRENT_CACHE_KEYS:
|
||||
backend.delete(cache_key)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _compile_filter_pattern(pattern: str) -> re.Pattern:
|
||||
@@ -291,11 +312,12 @@ class TorrentHelper:
|
||||
return []
|
||||
|
||||
# 下载规则
|
||||
priority_rule: List[str] = SystemConfigOper().get(
|
||||
priority_rule: List[str] = get_configured_system_config().get(
|
||||
SystemConfigKey.TorrentsPriority) or ["torrent", "upload", "seeder"]
|
||||
# 站点上传量
|
||||
site_uploads = {
|
||||
site.name: site.upload for site in SiteOper().get_userdata_latest()
|
||||
site.name: site.upload
|
||||
for site in get_configured_site_query_service().userdata_latest_sync()
|
||||
}
|
||||
|
||||
def get_sort_str(_context):
|
||||
|
||||
@@ -17,6 +17,50 @@ SUPPORTED_WORKFLOW_TRIGGERS = {
|
||||
}
|
||||
|
||||
|
||||
class AsyncWorkflowQueryRepository(Protocol):
|
||||
"""工作流查询用例需要的异步读取端口。"""
|
||||
|
||||
async def async_list(self) -> list[Any]:
|
||||
"""读取全部工作流。"""
|
||||
...
|
||||
|
||||
async def async_get(self, workflow_id: int) -> Optional[Any]:
|
||||
"""按 ID 读取工作流。"""
|
||||
...
|
||||
|
||||
|
||||
class WorkflowQueryService:
|
||||
"""提供工作流列表和详情查询,隔离 API 与数据库会话。"""
|
||||
|
||||
def __init__(self, repository: AsyncWorkflowQueryRepository) -> None:
|
||||
"""保存请求级异步查询端口。"""
|
||||
self._repository = repository
|
||||
|
||||
async def list(self) -> list[Any]:
|
||||
"""返回全部工作流。"""
|
||||
return await self._repository.async_list()
|
||||
|
||||
async def get(self, workflow_id: int) -> Optional[Any]:
|
||||
"""返回指定工作流。"""
|
||||
return await self._repository.async_get(workflow_id)
|
||||
|
||||
|
||||
_configured_workflow_query: WorkflowQueryService | None = None
|
||||
|
||||
|
||||
def configure_workflow_query(service: WorkflowQueryService) -> None:
|
||||
"""由启动组合根登记工作流查询服务。"""
|
||||
global _configured_workflow_query
|
||||
_configured_workflow_query = service
|
||||
|
||||
|
||||
def get_configured_workflow_query() -> WorkflowQueryService:
|
||||
"""返回启动阶段登记的工作流查询服务。"""
|
||||
if _configured_workflow_query is None:
|
||||
raise RuntimeError("工作流查询服务尚未配置")
|
||||
return _configured_workflow_query
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowMutationResult:
|
||||
"""描述工作流写操作是否成功及兼容提示信息。"""
|
||||
|
||||
Reference in New Issue
Block a user