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

This commit is contained in:
jxxghp
2026-08-18 13:22:02 +08:00
parent cca99bd421
commit 8472bcff43
274 changed files with 10730 additions and 6130 deletions
+127
View File
@@ -0,0 +1,127 @@
"""Agent 编排和工具使用的数据端口组合根注册表。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
AgentDataFactory = Callable[[], Any]
class _PortMeta(type):
"""支持旧测试按 Oper 方法打桩的端口代理元类。"""
def __getattr__(cls, name: str) -> Any:
"""把类级方法访问转发到当前配置端口。"""
return getattr(cls(), name)
class _PortProxy(metaclass=_PortMeta):
"""将存量 Oper 调用形态转发到 Agent 数据端口。"""
port_name: str
def __getattr__(self, name: str) -> Any:
"""转发未被测试替换的实例方法。"""
ports = get_agent_data_ports()
return getattr(getattr(ports, self.port_name)(), name)
class AgentChatPort(_PortProxy):
"""Agent 会话数据端口代理。"""
port_name = "agent_chat"
class AgentTaskPort(_PortProxy):
"""Agent 定时任务数据端口代理。"""
port_name = "agent_task"
class UserPort(_PortProxy):
"""用户数据端口代理。"""
port_name = "user"
class SitePort(_PortProxy):
"""站点数据端口代理。"""
port_name = "site"
class SubscribePort(_PortProxy):
"""订阅数据端口代理。"""
port_name = "subscribe"
class SubscribeHistoryPort(_PortProxy):
"""订阅历史数据端口代理。"""
port_name = "subscribe_history"
class TransferHistoryPort(_PortProxy):
"""整理历史数据端口代理。"""
port_name = "transfer_history"
class DownloadHistoryPort(_PortProxy):
"""下载历史数据端口代理。"""
port_name = "download_history"
class WorkflowPort(_PortProxy):
"""工作流数据端口代理。"""
port_name = "workflow"
class PluginDataPort(_PortProxy):
"""插件数据端口代理。"""
port_name = "plugin_data"
class AgentDataPorts:
"""Agent 入口所需的持久化端口集合。"""
def __init__(self, **factories: AgentDataFactory) -> None:
"""保存各数据能力的工厂。"""
self.__dict__.update(factories)
_ports: AgentDataPorts | None = None
def configure_agent_data_ports(**factories: AgentDataFactory) -> None:
"""由启动组合根登记 Agent 数据端口实现。"""
required = {
"agent_chat",
"agent_task",
"user",
"site",
"subscribe",
"subscribe_history",
"transfer_history",
"download_history",
"workflow",
"plugin_data",
}
missing = sorted(required - factories.keys())
if missing:
raise ValueError(f"Agent 数据端口缺少实现: {', '.join(missing)}")
global _ports
_ports = AgentDataPorts(**{name: factories[name] for name in required})
def get_agent_data_ports() -> AgentDataPorts:
"""返回已登记的 Agent 数据端口。"""
if _ports is None:
raise RuntimeError("Agent 数据端口尚未配置")
return _ports
+10 -23
View File
@@ -6,15 +6,11 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.db.oper.message import MessageOper
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.plugin_manager import PluginManager
from app.application.chain.data import ChainDataPorts
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
ModuleDispatcherFactory = Callable[..., Any]
ChainRuntimeContextProvider = Callable[[], "ChainRuntimeContext"]
@@ -30,33 +26,24 @@ class ChainRuntimeContext:
file_cache: Any
async_file_cache: Any
message_queue_factory: MessageQueueFactory
module_dispatcher_factory: ModuleDispatcherFactory
data_ports: Optional[ChainDataPorts] = None
def build_default_chain_runtime_context() -> ChainRuntimeContext:
"""按旧构造规则创建上下文,同时复用各管理器既有单例身份"""
return ChainRuntimeContext(
module_manager=ModuleManager(),
plugin_manager=PluginManager(),
event_manager=EventManager(),
message_oper=MessageOper(),
message_helper=MessageHelper(),
file_cache=FileCache(),
async_file_cache=AsyncFileCache(),
message_queue_factory=lambda callback: MessageQueueManager(
send_callback=callback
),
)
def _unconfigured_chain_runtime_context() -> ChainRuntimeContext:
"""拒绝在组合根装配前隐式抓取全局管理器"""
raise RuntimeError("Chain 运行上下文尚未由启动组合根配置")
_context_provider: ChainRuntimeContextProvider = build_default_chain_runtime_context
_context_provider: ChainRuntimeContextProvider = _unconfigured_chain_runtime_context
def configure_chain_runtime_context_provider(
provider: Optional[ChainRuntimeContextProvider],
) -> None:
"""由组合根替换 Chain 上下文来源;传入空值恢复兼容默认值"""
"""由组合根替换 Chain 上下文来源;传入空值恢复未配置状态"""
global _context_provider
_context_provider = provider or build_default_chain_runtime_context
_context_provider = provider or _unconfigured_chain_runtime_context
def get_chain_runtime_context() -> ChainRuntimeContext:
+176
View File
@@ -0,0 +1,176 @@
"""Chain 所需持久化端口的组合根注册表。
Chain 只依赖本模块声明的工厂,不再直接导入数据库 Oper 或 ORM 模型。
具体适配器由 ``app.startup`` 在进程启动时装配,测试也可以登记隔离替身。
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
OperFactory = Callable[[], Any]
@dataclass(frozen=True, slots=True)
class ChainDataPorts:
"""跨领域 Chain 使用的最小持久化端口工厂集合。"""
site: OperFactory
subscribe: OperFactory
workflow: OperFactory
download_history: OperFactory
transfer_history: OperFactory
transfer_pending: OperFactory
media_server: OperFactory
download_failure: OperFactory
user: OperFactory
class _PortProxyMeta(type):
"""让迁移期的 Oper 名称支持按方法打桩,同时仍转发到组合根端口。"""
def __getattr__(cls, name: str) -> Any:
"""把类级方法访问转发到一个新的端口实例。"""
return getattr(cls(), name)
class _ChainDataPortProxy(metaclass=_PortProxyMeta):
"""将旧的 Oper 调用形态转发到 Chain 数据端口的内部代理。"""
port_name: str
def __getattr__(self, name: str) -> Any:
"""转发未被测试替换的数据操作。"""
return getattr(getattr(get_chain_data_ports(), self.port_name)(), name)
class SitePortProxy(_ChainDataPortProxy):
"""站点数据端口代理。"""
port_name = "site"
class SubscribePortProxy(_ChainDataPortProxy):
"""订阅数据端口代理。"""
port_name = "subscribe"
class WorkflowPortProxy(_ChainDataPortProxy):
"""工作流数据端口代理。"""
port_name = "workflow"
class DownloadHistoryPortProxy(_ChainDataPortProxy):
"""下载历史数据端口代理。"""
port_name = "download_history"
class TransferHistoryPortProxy(_ChainDataPortProxy):
"""整理历史数据端口代理。"""
port_name = "transfer_history"
class TransferPendingPortProxy(_ChainDataPortProxy):
"""待整理数据端口代理。"""
port_name = "transfer_pending"
class MediaServerPortProxy(_ChainDataPortProxy):
"""媒体服务器数据端口代理。"""
port_name = "media_server"
class DownloadFailurePortProxy(_ChainDataPortProxy):
"""下载失败数据端口代理。"""
port_name = "download_failure"
class UserPortProxy(_ChainDataPortProxy):
"""用户数据端口代理。"""
port_name = "user"
_ports: Optional[ChainDataPorts] = None
def configure_chain_data_ports(**factories: OperFactory) -> None:
"""由启动组合根登记 Chain 的数据端口实现。"""
required = {
"site",
"subscribe",
"workflow",
"download_history",
"transfer_history",
"transfer_pending",
"media_server",
"download_failure",
"user",
}
missing = sorted(required - factories.keys())
if missing:
raise ValueError(f"Chain 数据端口缺少实现: {', '.join(missing)}")
global _ports
_ports = ChainDataPorts(**{name: factories[name] for name in required})
def get_chain_data_ports() -> ChainDataPorts:
"""返回启动阶段登记的 Chain 数据端口。"""
if _ports is None:
raise RuntimeError("Chain 数据端口尚未配置")
return _ports
def get_chain_site_port() -> Any:
"""创建站点数据端口实例。"""
return get_chain_data_ports().site()
def get_chain_subscribe_port() -> Any:
"""创建订阅数据端口实例。"""
return get_chain_data_ports().subscribe()
def get_chain_workflow_port() -> Any:
"""创建工作流数据端口实例。"""
return get_chain_data_ports().workflow()
def get_chain_download_history_port() -> Any:
"""创建下载历史数据端口实例。"""
return get_chain_data_ports().download_history()
def get_chain_transfer_history_port() -> Any:
"""创建整理历史数据端口实例。"""
return get_chain_data_ports().transfer_history()
def get_chain_transfer_pending_port() -> Any:
"""创建待整理数据端口实例。"""
return get_chain_data_ports().transfer_pending()
def get_chain_media_server_port() -> Any:
"""创建媒体服务器数据端口实例。"""
return get_chain_data_ports().media_server()
def get_chain_download_failure_port() -> Any:
"""创建下载失败数据端口实例。"""
return get_chain_data_ports().download_failure()
def get_chain_user_port() -> Any:
"""创建用户数据端口实例。"""
return get_chain_data_ports().user()
+68
View File
@@ -0,0 +1,68 @@
"""系统配置应用服务与组合根注入点。"""
from __future__ import annotations
from typing import Any, Protocol
class ConfigurationRepository(Protocol):
"""配置服务所需的最小持久化端口。"""
def get(self, key: Any = None) -> Any:
"""读取配置。"""
def set(self, key: Any, value: Any) -> bool | None:
"""写入配置。"""
async def async_get(self, key: Any = None) -> Any:
"""异步读取配置。"""
async def async_set(self, key: Any, value: Any) -> bool | None:
"""异步写入配置。"""
def delete(self, key: Any) -> Any:
"""删除配置。"""
class SystemConfigService:
"""系统配置读写应用服务。"""
def __init__(self, repository: ConfigurationRepository) -> None:
"""注入配置数据端口。"""
self._repository = repository
def get(self, key: Any = None) -> Any:
"""读取配置。"""
return self._repository.get(key)
def set(self, key: Any, value: Any) -> bool | None:
"""写入配置。"""
return self._repository.set(key, value)
async def async_get(self, key: Any = None) -> Any:
"""异步读取配置。"""
return await self._repository.async_get(key)
async def async_set(self, key: Any, value: Any) -> bool | None:
"""异步写入配置。"""
return await self._repository.async_set(key, value)
def delete(self, key: Any) -> Any:
"""删除配置。"""
return self._repository.delete(key)
_configured_system_config: SystemConfigService | None = None
def configure_system_config(service: SystemConfigService) -> None:
"""由启动组合根登记系统配置服务。"""
global _configured_system_config
_configured_system_config = service
def get_configured_system_config() -> SystemConfigService:
"""返回启动阶段登记的系统配置服务。"""
if _configured_system_config is None:
raise RuntimeError("系统配置服务尚未配置")
return _configured_system_config
+64
View File
@@ -0,0 +1,64 @@
"""Dashboard 统计查询用例。"""
from collections.abc import Callable
from typing import Any, Optional, Protocol
from app.schemas.dashboard import Statistic
class TransferHistoryQueryRepository(Protocol):
"""Dashboard 所需的整理历史统计端口。"""
def monthly_media_statistics(self) -> tuple[int, int, int, int]:
"""返回本月电影、剧集、单集和音乐数量。"""
...
async def async_statistic(self, days: int = 7) -> list[Any]:
"""返回最近若干天的整理趋势。"""
...
class DashboardQueryService:
"""汇总媒体服务数据与整理历史统计。"""
def __init__(
self,
*,
repository: TransferHistoryQueryRepository,
media_statistics: Callable[[Optional[str]], Optional[list[Statistic]]],
) -> None:
"""保存整理历史端口和媒体服务统计提供方。"""
self._repository = repository
self._media_statistics = media_statistics
def statistic(self, name: Optional[str] = None) -> Statistic:
"""返回媒体服务总量和本月新增量。"""
media_statistics = self._media_statistics(name)
if media_statistics:
result = Statistic()
has_episode_count = False
for item in media_statistics:
result.movie_count += item.movie_count or 0
result.tv_count += item.tv_count or 0
result.music_count += item.music_count or 0
result.user_count += item.user_count or 0
if item.episode_count is not None:
result.episode_count += item.episode_count or 0
has_episode_count = True
if not has_episode_count:
result.episode_count = None
else:
result = Statistic()
(
result.movie_count_month,
result.tv_count_month,
result.episode_count_month,
result.music_count_month,
) = self._repository.monthly_media_statistics()
return result
async def transfer(self, days: int = 7) -> list[int]:
"""返回最近若干天的整理数量序列。"""
rows = await self._repository.async_statistic(days)
return [row[1] for row in rows]
+37
View File
@@ -0,0 +1,37 @@
"""数据库连通性应用服务。"""
from __future__ import annotations
from collections.abc import Callable
from typing import Optional
DatabaseProbe = Callable[[], Optional[str]]
class DatabaseHealthService:
"""为模块和诊断入口提供不暴露会话实现的数据库探测能力。"""
def __init__(self, probe: DatabaseProbe) -> None:
"""保存由组合根提供的数据库探测端口。"""
self._probe = probe
def test(self) -> Optional[str]:
"""执行数据库探测,成功返回空值,失败返回说明。"""
return self._probe()
_configured_database_health: DatabaseHealthService | None = None
def configure_database_health(service: DatabaseHealthService) -> None:
"""由启动组合根登记数据库探测服务。"""
global _configured_database_health
_configured_database_health = service
def get_configured_database_health() -> DatabaseHealthService:
"""返回启动阶段登记的数据库探测服务。"""
if _configured_database_health is None:
raise RuntimeError("数据库探测服务尚未配置")
return _configured_database_health
+2 -2
View File
@@ -5,7 +5,7 @@ from typing import List, Optional, Tuple
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.domain.context import MediaInfo
from app.db.oper.systemconfig import SystemConfigOper
from app.application.configuration import get_configured_system_config
from app.runtime.log import logger
from app.schemas.types import MediaType, StorageSchema, SystemConfigKey
from app.adapters.system.host import SystemUtils
@@ -25,7 +25,7 @@ class DirectoryHelper:
"""
获取所有下载目录
"""
dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories)
dir_confs: List[dict] = get_configured_system_config().get(SystemConfigKey.Directories)
if not dir_confs:
return []
return [_SchemaTransferDirectoryConf(**d) for d in dir_confs]
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import Optional
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.application.service import ServiceBaseHelper
from app.schemas.system import DownloaderConf
from app.schemas.system import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
+282 -15
View File
@@ -6,11 +6,15 @@ from app.domain.context import MediaInfo, MusicInfo
from app.schemas.media import resolve_media_identity
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.foundation.text import cut as jieba_cut
from app.runtime.cache import TTLCache
from app.runtime.config import settings
from app.db.models.transferhistory import TransferHistory
from app.db.oper.transferhistory import TransferHistoryOper
from app.runtime.log import logger
from app.schemas.history import (
DownloadHistory as DownloadHistoryView,
TransferHistory as TransferHistoryView,
TransferHistoryPage,
)
from app.schemas.workflow import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MUSIC_ENTITY_RECORDING
@@ -28,6 +32,59 @@ FAILED_RETRY_TTL = 24 * 3600
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)
class TransferHistoryRecord(Protocol):
"""整理历史用例读取的最小记录投影。"""
id: int
status: bool
src: Optional[str]
src_storage: Optional[str]
src_fileitem: Optional[dict]
class TransferHistoryWriter(Protocol):
"""整理历史写入和查重端口。"""
def get_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]:
"""按源路径读取记录。"""
def get_success_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]:
"""按源路径读取成功记录。"""
def add_force(self, **payload: Any) -> Optional[TransferHistoryRecord]:
"""强制写入整理历史。"""
_configured_transfer_history_provider: Callable[[], TransferHistoryWriter] | None = None
def configure_transfer_history_provider(
provider: Callable[[], TransferHistoryWriter],
) -> None:
"""由启动组合根登记整理历史数据端口提供器。"""
global _configured_transfer_history_provider
_configured_transfer_history_provider = provider
def _get_transfer_history_writer(
writer: Optional[TransferHistoryWriter],
) -> TransferHistoryWriter:
"""获取显式传入或组合根登记的整理历史数据端口。"""
if writer is not None:
return writer
if _configured_transfer_history_provider is None:
raise RuntimeError("整理历史数据端口尚未配置")
return _configured_transfer_history_provider()
class TransferHistoryPort:
"""把监控等宿主用例的存量构造形态转发到整理历史端口。"""
def __getattr__(self, name: str) -> Any:
"""转发整理历史读写方法,避免上层直接导入数据库操作器。"""
return getattr(_get_transfer_history_writer(None), name)
@dataclass(frozen=True, slots=True)
class HistoryMutationResult:
"""描述历史记录维护操作是否成功及兼容提示。"""
@@ -36,6 +93,216 @@ class HistoryMutationResult:
message: str = ""
class AsyncDownloadHistoryQueryRepository(Protocol):
"""下载历史只读用例需要的最小异步持久化端口。"""
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
) -> list[Any]:
"""按下载时间倒序分页读取历史记录。"""
...
class AsyncTransferHistoryQueryRepository(Protocol):
"""整理历史列表和详情查询需要的最小异步持久化端口。"""
async def async_get(self, historyid: int) -> Optional[Any]:
"""按主键读取单条整理历史。"""
...
async def async_list_by_title(
self,
title: str,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
wildcard: bool = False,
) -> list[Any]:
"""按标题或路径分页读取整理历史。"""
...
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> list[Any]:
"""按时间倒序分页读取整理历史。"""
...
async def async_count(self, status: Optional[bool] = None) -> Optional[int]:
"""统计指定状态的整理历史数量。"""
...
async def async_count_by_title(
self,
title: str,
status: Optional[bool] = None,
wildcard: bool = False,
) -> Optional[int]:
"""统计匹配标题或路径的整理历史数量。"""
...
@dataclass(frozen=True, slots=True)
class ManualTransferHistory:
"""手动整理准备阶段需要的稳定历史投影。"""
id: int
status: bool
mode: Optional[str]
src_fileitem: Optional[dict]
dest_fileitem: Optional[dict]
downloader: Optional[str]
download_hash: Optional[str]
type: Optional[str]
media_source: Optional[str]
media_id: Optional[str]
music_type: Optional[str]
seasons: Optional[str]
episodes: Optional[str]
episode_group: Optional[str]
class TransferHistoryLookupRepository(Protocol):
"""手动整理历史投影所需的同步查询端口。"""
def get(self, history_id: int) -> Optional[Any]:
"""按主键读取整理历史。"""
...
class TransferHistoryLookupService:
"""向同步整理用例提供脱离 ORM 会话的历史投影。"""
def __init__(self, repository: TransferHistoryLookupRepository) -> None:
"""保存整理历史只读端口。"""
self._repository = repository
def get(self, history_id: int) -> Optional[ManualTransferHistory]:
"""按主键读取手动整理所需字段。"""
record = self._repository.get(history_id)
if record is None:
return None
return ManualTransferHistory(
id=record.id,
status=bool(record.status),
mode=record.mode,
src_fileitem=record.src_fileitem,
dest_fileitem=record.dest_fileitem,
downloader=record.downloader,
download_hash=record.download_hash,
type=record.type,
media_source=record.media_source,
media_id=record.media_id,
music_type=getattr(record, "music_type", None),
seasons=record.seasons,
episodes=record.episodes,
episode_group=record.episode_group,
)
class HistoryQueryService:
"""提供历史列表和详情 DTO,隔离 API 与数据库模型。"""
def __init__(
self,
*,
download_repository: AsyncDownloadHistoryQueryRepository,
transfer_repository: AsyncTransferHistoryQueryRepository,
) -> None:
"""保存下载历史和整理历史的只读端口。"""
self._download_repository = download_repository
self._transfer_repository = transfer_repository
async def list_download(
self,
*,
page: int = 1,
count: int = 30,
) -> list[DownloadHistoryView]:
"""分页读取下载历史并转换为稳定的接口 DTO。"""
records = await self._download_repository.async_list_by_page(page, count)
return [DownloadHistoryView.model_validate(record) for record in records]
async def list_transfer(
self,
*,
title: Optional[str] = None,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> TransferHistoryPage:
"""应用历史筛选规则并返回整理历史分页 DTO。"""
if title == "失败":
title = None
status = False
elif title == "成功":
title = None
status = True
if title:
wildcard = "*" in title or "?" in title
if wildcard:
pattern = self._glob_to_like(title)
else:
pattern = "%".join(jieba_cut(title, HMM=False))
total = await self._transfer_repository.async_count_by_title(
pattern,
status=status,
wildcard=wildcard,
)
records = await self._transfer_repository.async_list_by_title(
pattern,
page=page,
count=count,
status=status,
wildcard=wildcard,
)
else:
records = await self._transfer_repository.async_list_by_page(
page=page,
count=count,
status=status,
)
total = await self._transfer_repository.async_count(status=status)
return TransferHistoryPage(
list=[TransferHistoryView.model_validate(record) for record in records],
total=int(total or 0),
)
async def get_transfer(self, history_id: int) -> Optional[TransferHistoryView]:
"""读取单条整理历史 DTO,不向调用方泄漏 ORM 实例。"""
record = await self._transfer_repository.async_get(history_id)
if record is None:
return None
return TransferHistoryView.model_validate(record)
async def get_transfers(
self,
history_ids: list[int],
) -> tuple[list[TransferHistoryView], list[int]]:
"""按输入顺序读取多条整理历史,并同时返回缺失 ID。"""
records: list[TransferHistoryView] = []
missing_ids: list[int] = []
for history_id in history_ids:
record = await self.get_transfer(history_id)
if record is None:
missing_ids.append(history_id)
else:
records.append(record)
return records, missing_ids
@staticmethod
def _glob_to_like(pattern: str) -> str:
"""将 glob 通配符转换为使用反斜杠转义的 SQL LIKE 模式。"""
result = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return result.replace("*", "%").replace("?", "_")
class DownloadHistoryMutationRepository(Protocol):
"""下载历史删除用例需要的最小持久化端口。"""
@@ -447,7 +714,7 @@ def coerce_size(size: Any) -> Optional[int]:
return None
def history_src_size(history: TransferHistory) -> Optional[int]:
def history_src_size(history: TransferHistoryRecord) -> Optional[int]:
"""
读取整理记录中的源文件大小。
src_fileitem 是 JSON 列,历史数据可能为空、缺 size 键甚至不是字典,
@@ -458,7 +725,7 @@ def history_src_size(history: TransferHistory) -> Optional[int]:
return history_src_fingerprint(history).get("size")
def history_src_fingerprint(history: TransferHistory) -> Dict[str, Any]:
def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]:
"""
读取整理记录中的源文件版本指纹。
:param history: 整理记录
@@ -475,8 +742,8 @@ def history_src_fingerprint(history: TransferHistory) -> Dict[str, Any]:
def resolve_history(src_path: str, storage: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryOper] = None
) -> Optional[TransferHistory]:
transfer_history_oper: Optional[TransferHistoryWriter] = None
) -> Optional[TransferHistoryRecord]:
"""
查询源路径对应的整理记录。
@@ -488,14 +755,14 @@ def resolve_history(src_path: str, storage: Optional[str] = None,
:param transfer_history_oper: 复用的历史操作对象,未传时新建
:return: 命中的整理记录,未命中时为 None
"""
oper = transfer_history_oper or TransferHistoryOper()
oper = _get_transfer_history_writer(transfer_history_oper)
history = oper.get_by_src(src_path, storage=storage)
if history is not None and not history.status:
history = oper.get_success_by_src(src_path, storage=storage) or history
return history
def evaluate_history_gate(history: Optional[TransferHistory],
def evaluate_history_gate(history: Optional[TransferHistoryRecord],
file_size: Optional[float] = None,
file_modify_time: Optional[float] = None,
fileid: Optional[str] = None,
@@ -547,7 +814,7 @@ def evaluate_history_gate(history: Optional[TransferHistory],
return HistoryGateAction.SKIP
def describe_history_gate(history: Optional[TransferHistory],
def describe_history_gate(history: Optional[TransferHistoryRecord],
file_size: Optional[float] = None,
file_modify_time: Optional[float] = None,
fileid: Optional[str] = None) -> str:
@@ -609,8 +876,8 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
mediainfo: Union[MediaInfo, MusicInfo], transferinfo: TransferInfo,
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryOper] = None
) -> Optional[TransferHistory]:
transfer_history_oper: Optional[TransferHistoryWriter] = None
) -> Optional[TransferHistoryRecord]:
"""
新增转移成功历史记录。
:param fileitem: 源文件项
@@ -623,7 +890,7 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
:param transfer_history_oper: 复用的历史操作对象,未传时新建
:return: 落库后的整理记录
"""
oper = transfer_history_oper or TransferHistoryOper()
oper = _get_transfer_history_writer(transfer_history_oper)
media_source, media_id = resolve_media_identity(media=mediainfo)
return oper.add_force(
src=fileitem.path,
@@ -661,8 +928,8 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
transferinfo: Optional[TransferInfo] = None,
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryOper] = None
) -> Optional[TransferHistory]:
transfer_history_oper: Optional[TransferHistoryWriter] = None
) -> Optional[TransferHistoryRecord]:
"""
新增转移失败历史记录。
@@ -678,7 +945,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
:param transfer_history_oper: 复用的历史操作对象,未传时新建
:return: 落库后的整理记录
"""
oper = transfer_history_oper or TransferHistoryOper()
oper = _get_transfer_history_writer(transfer_history_oper)
if mediainfo and transferinfo:
media_source, media_id = resolve_media_identity(media=mediainfo)
his = oper.add_force(
+15 -7
View File
@@ -9,8 +9,6 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable, ContextManager, Dict, Optional, Protocol
from app.db.maintenance import DatabaseCleanupRepository
from app.db.session import SessionFactory
from app.runtime.config import settings
from app.runtime.log import logger
@@ -327,11 +325,21 @@ def read_cleanup_policy() -> CleanupPolicy:
def build_cleanup_service() -> DataCleanupService:
"""在应用边界组装默认数据库适配器,供兼容调度门面触发"""
return DataCleanupService(
repository=DatabaseCleanupRepository(session_factory=SessionFactory),
policy_reader=read_cleanup_policy,
)
"""返回启动组合根登记的清理服务"""
if _configured_cleanup_service_factory is None:
raise RuntimeError("数据清理服务尚未配置")
return _configured_cleanup_service_factory()
_configured_cleanup_service_factory: Callable[[], DataCleanupService] | None = None
def configure_cleanup_service_factory(
factory: Callable[[], DataCleanupService],
) -> None:
"""由启动组合根登记数据清理服务工厂。"""
global _configured_cleanup_service_factory
_configured_cleanup_service_factory = factory
def _normalize_days(retention_days: Any) -> int:
+39 -2
View File
@@ -1,11 +1,11 @@
import re
from collections.abc import Iterable, Mapping
from typing import Any, Optional
from typing import Any, Optional, Protocol
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.domain.context import MusicInfo
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.application.service import ServiceBaseHelper
from app.schemas.system import MediaServerConf
from app.schemas.system import ServiceInfo
from app.schemas.types import (
@@ -16,6 +16,43 @@ from app.schemas.types import (
)
class AsyncMediaServerQueryRepository(Protocol):
"""媒体服务器本地条目查询所需的异步持久化端口。"""
async def async_exists(self, **kwargs: Any) -> Any | None:
"""按标题或统一媒体身份查找已同步条目。"""
...
class MediaServerQueryService:
"""封装媒体服务器本地存在性查询与 ORM 投影。"""
def __init__(self, repository: AsyncMediaServerQueryRepository):
"""使用显式媒体服务器查询端口初始化服务。"""
self._repository = repository
async def find_item_id(
self,
*,
title: Optional[str] = None,
year: Optional[str] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
item = await self._repository.async_exists(
title=title,
year=year,
mtype=mtype,
media_source=media_source,
media_id=media_id,
season=season,
)
return item.item_id if item else None
class MediaServerIdentityHelper:
"""将媒体服务器专有 ProviderIds 适配为统一媒体身份。"""
+245
View File
@@ -0,0 +1,245 @@
"""Agent 会话历史的查询、授权与删除应用服务。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional, Protocol
from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary
class AgentChatPrincipal(Protocol):
"""会话访问控制所需的最小用户身份。"""
id: Any
name: Optional[str]
is_superuser: bool
class AsyncAgentChatRepository(Protocol):
"""Agent 会话用例需要的最小异步持久化端口。"""
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
user_id: Optional[str] = None,
username: Optional[str] = None,
) -> list[Any]:
"""分页读取用户可见的会话。"""
...
async def async_get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[Any]:
"""按服务端会话 ID 读取记录。"""
...
async def async_delete(
self,
session_id: str,
user_id: Optional[str] = None,
) -> bool:
"""删除指定服务端会话。"""
...
def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]:
"""同步读取服务端会话。"""
...
def save_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[Any]:
"""同步保存用户可见会话消息。"""
...
@dataclass(frozen=True, slots=True)
class AgentChatRecord:
"""脱离 ORM 会话的 Agent 会话持久化投影。"""
id: Optional[int]
session_id: str
client_session_id: Optional[str]
title: Optional[str]
channel: Optional[str]
source: Optional[str]
user_id: Optional[str]
username: Optional[str]
original_chat_id: Optional[str]
message_count: int
created_at: Any
updated_at: Any
messages: list[dict]
class AgentChatService:
"""统一执行 Agent 会话查询、访问控制和删除。"""
def __init__(self, repository: AsyncAgentChatRepository) -> None:
"""保存异步会话持久化端口。"""
self._repository = repository
async def list(
self,
principal: AgentChatPrincipal,
*,
page: int = 1,
count: int = 30,
) -> list[AgentChatSessionSummary]:
"""分页返回当前用户可见的会话摘要。"""
user_id = None if principal.is_superuser else str(principal.id)
username = None if principal.is_superuser else principal.name
records = await self._repository.async_list_by_page(
page=page,
count=count,
user_id=user_id,
username=username,
)
return [self.to_summary(self._project(record)) for record in records]
async def get_accessible(
self,
session_id: str,
principal: AgentChatPrincipal,
) -> Optional[AgentChatRecord]:
"""读取会话并在应用边界执行访问控制。"""
projected = await self.get(session_id)
if projected is None:
return None
if not self.can_access(projected, principal):
return None
return projected
async def get(self, session_id: str) -> Optional[AgentChatRecord]:
"""读取不附带授权判断的会话投影。"""
record = await self._repository.async_get(session_id=session_id)
if record is None:
return None
return self._project(record)
async def delete(
self,
session_id: str,
principal: AgentChatPrincipal,
) -> bool:
"""仅在当前用户可访问时删除会话。"""
record = await self.get_accessible(session_id, principal)
if record is None:
return False
return await self._repository.async_delete(session_id=session_id)
def get_sync(self, session_id: str) -> Optional[AgentChatRecord]:
"""同步读取会话投影,供同步 Agent 编排路径使用。"""
record = self._repository.get(session_id=session_id)
return self._project(record) if record is not None else None
def save_display_sync(
self,
*,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[AgentChatRecord]:
"""同步保存用户可见消息并返回最新投影。"""
record = self._repository.save_display_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
return self._project(record) if record is not None else None
@staticmethod
def can_access(
record: AgentChatRecord,
principal: AgentChatPrincipal,
) -> bool:
"""判断用户是否拥有会话访问权。"""
if principal.is_superuser:
return True
user_id = str(principal.id)
username = str(principal.name or "")
return record.user_id == user_id or (
bool(username) and record.username == username
)
@staticmethod
def to_summary(record: AgentChatRecord) -> AgentChatSessionSummary:
"""把持久化投影转换为会话摘要 DTO。"""
return AgentChatSessionSummary(
id=record.id,
session_id=record.session_id,
client_session_id=record.client_session_id,
title=record.title,
channel=record.channel,
source=record.source,
user_id=record.user_id,
username=record.username,
original_chat_id=record.original_chat_id,
message_count=record.message_count,
created_at=record.created_at,
updated_at=record.updated_at,
)
@classmethod
def to_detail(cls, record: AgentChatRecord) -> AgentChatSessionDetail:
"""把持久化投影转换为会话详情 DTO。"""
return AgentChatSessionDetail(
**cls.to_summary(record).model_dump(),
messages=record.messages,
)
@staticmethod
def _project(record: Any) -> AgentChatRecord:
"""立即复制 ORM 字段,避免对象越过请求级会话边界。"""
return AgentChatRecord(
id=record.id,
session_id=record.session_id,
client_session_id=record.client_session_id,
title=record.title,
channel=record.channel,
source=record.source,
user_id=record.user_id,
username=record.username,
original_chat_id=record.original_chat_id,
message_count=record.message_count or 0,
created_at=record.created_at,
updated_at=record.updated_at,
messages=list(record.display_messages or []),
)
_configured_agent_chat_service: AgentChatService | None = None
def configure_agent_chat_service(service: AgentChatService) -> None:
"""由启动组合根登记同步 Agent 会话服务。"""
global _configured_agent_chat_service
_configured_agent_chat_service = service
def get_configured_agent_chat_service() -> AgentChatService:
"""返回启动阶段登记的 Agent 会话服务。"""
if _configured_agent_chat_service is None:
raise RuntimeError("Agent 会话服务尚未配置")
return _configured_agent_chat_service
+62 -4
View File
@@ -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
+21 -9
View File
@@ -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} 不存在"
+56 -8
View File
@@ -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 会话可见的市场技能,并应用搜索词过滤。
"""
+25 -13
View File
@@ -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,
+59
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
from typing import Optional
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.application.service import ServiceBaseHelper
from app.schemas.system import NotificationConf
from app.schemas.system import ServiceInfo
from app.schemas.types import ModuleType, SystemConfigKey
+4
View File
@@ -13,3 +13,7 @@ class DynamicRouteRegistry(Protocol):
def remove(self, plugin_id: str) -> bool:
"""移除指定插件的全部动态路由。"""
...
def clean(self, existing_paths: dict) -> None:
"""清理重建过程中可能重复的受保护路由。"""
...
+35
View File
@@ -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
View File
@@ -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 {}
+11 -7
View File
@@ -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)
+3 -3
View File
@@ -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]
+8
View File
@@ -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()
-471
View File
@@ -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()
+121 -47
View File
@@ -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)
+75
View File
@@ -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
+201
View File
@@ -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()
+108
View File
@@ -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
+47
View File
@@ -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
+154
View File
@@ -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),
)
+124
View File
@@ -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
+65
View File
@@ -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
+11
View File
@@ -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:
+171
View File
@@ -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
+5 -5
View File
@@ -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])
+39 -6
View File
@@ -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)
+149
View File
@@ -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
+143 -1
View File
@@ -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,
+26 -4
View File
@@ -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):
+44
View File
@@ -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:
"""描述工作流写操作是否成功及兼容提示信息。"""