mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Chain 运行时依赖组合。"""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Chain 兼容门面所需运行时依赖的显式上下文。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
|
||||
ChainRuntimeContextProvider = Callable[[], "ChainRuntimeContext"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChainRuntimeContext:
|
||||
"""集中声明 Chain 调度、事件、消息和缓存所需的最小运行时对象。"""
|
||||
|
||||
module_manager: Any
|
||||
plugin_manager: Any
|
||||
event_manager: Any
|
||||
message_oper: Any
|
||||
message_helper: Any
|
||||
file_cache: Any
|
||||
async_file_cache: Any
|
||||
message_queue_factory: MessageQueueFactory
|
||||
|
||||
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_context_provider: ChainRuntimeContextProvider = build_default_chain_runtime_context
|
||||
|
||||
|
||||
def configure_chain_runtime_context_provider(
|
||||
provider: Optional[ChainRuntimeContextProvider],
|
||||
) -> None:
|
||||
"""由组合根替换 Chain 上下文来源;传入空值恢复兼容默认值。"""
|
||||
global _context_provider
|
||||
_context_provider = provider or build_default_chain_runtime_context
|
||||
|
||||
|
||||
def get_chain_runtime_context() -> ChainRuntimeContext:
|
||||
"""返回当前组合根提供的 Chain 运行上下文。"""
|
||||
return _context_provider()
|
||||
@@ -2,7 +2,8 @@ import re
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from app import schemas
|
||||
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.runtime.log import logger
|
||||
@@ -20,22 +21,22 @@ class DirectoryHelper:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_dirs() -> List[schemas.TransferDirectoryConf]:
|
||||
def get_dirs() -> List[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
获取所有下载目录
|
||||
"""
|
||||
dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories)
|
||||
if not dir_confs:
|
||||
return []
|
||||
return [schemas.TransferDirectoryConf(**d) for d in dir_confs]
|
||||
return [_SchemaTransferDirectoryConf(**d) for d in dir_confs]
|
||||
|
||||
def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||
def get_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
获取所有下载目录
|
||||
"""
|
||||
return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority)
|
||||
|
||||
def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||
def get_local_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
获取所有本地的可下载目录
|
||||
"""
|
||||
@@ -45,7 +46,7 @@ class DirectoryHelper:
|
||||
self,
|
||||
media: Optional[MediaInfo],
|
||||
save_path: str,
|
||||
) -> Optional[schemas.TransferDirectoryConf]:
|
||||
) -> Optional[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
按媒体信息和精确保存根路径匹配下载目录配置。
|
||||
|
||||
@@ -78,13 +79,13 @@ class DirectoryHelper:
|
||||
return dir_info
|
||||
return None
|
||||
|
||||
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||
def get_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
获取所有媒体库目录
|
||||
"""
|
||||
return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority)
|
||||
|
||||
def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||
def get_local_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
获取所有本地的媒体库目录
|
||||
"""
|
||||
@@ -93,7 +94,7 @@ class DirectoryHelper:
|
||||
def get_dir(self, media: Optional[MediaInfo], include_unsorted: Optional[bool] = False,
|
||||
storage: Optional[str] = None, src_path: Path = None,
|
||||
target_storage: Optional[str] = None, dest_path: Path = None
|
||||
) -> Optional[schemas.TransferDirectoryConf]:
|
||||
) -> Optional[_SchemaTransferDirectoryConf]:
|
||||
"""
|
||||
根据媒体信息获取下载目录、媒体库目录配置
|
||||
:param media: 媒体信息
|
||||
@@ -113,7 +114,7 @@ class DirectoryHelper:
|
||||
dirs_to_consider = matching_dirs if matching_dirs else dirs
|
||||
|
||||
# 已匹配的目录
|
||||
matched_dirs: List[schemas.TransferDirectoryConf] = []
|
||||
matched_dirs: List[_SchemaTransferDirectoryConf] = []
|
||||
# 按照配置顺序查找
|
||||
for d in dirs_to_consider:
|
||||
# 没有启用整理的目录
|
||||
@@ -297,10 +298,10 @@ def _download_path_uri(storage: str, path: PurePath) -> str:
|
||||
path_value = path.as_posix()
|
||||
if storage == "local":
|
||||
return path_value
|
||||
return schemas.FileURI(storage=storage, path=path_value).uri
|
||||
return _SchemaFileURI(storage=storage, path=path_value).uri
|
||||
|
||||
|
||||
def _normalize_download_root(dir_info: schemas.TransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
|
||||
def _normalize_download_root(dir_info: _SchemaTransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
|
||||
"""
|
||||
读取下载目录配置中的根路径;无效配置不参与用户 save_path allowlist。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""下载应用服务。"""
|
||||
@@ -0,0 +1,77 @@
|
||||
"""下载任务查询与控制应用服务。"""
|
||||
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from app.schemas.transfer import DownloaderTorrent
|
||||
from app.schemas.types import TorrentStatus
|
||||
|
||||
|
||||
class DownloadTaskService:
|
||||
"""通过下载器和历史端口查询、启停及删除下载任务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
list_torrents: Callable[..., List[DownloaderTorrent]],
|
||||
get_history_by_hashes: Callable[[list[str]], dict],
|
||||
start_torrents: Callable[..., bool],
|
||||
stop_torrents: Callable[..., bool],
|
||||
remove_torrents: Callable[..., bool],
|
||||
) -> None:
|
||||
"""注入下载器操作和历史读取端口。"""
|
||||
self._list_torrents = list_torrents
|
||||
self._get_history_by_hashes = get_history_by_hashes
|
||||
self._start_torrents = start_torrents
|
||||
self._stop_torrents = stop_torrents
|
||||
self._remove_torrents = remove_torrents
|
||||
|
||||
def downloading(self, name: Optional[str] = None) -> List[DownloaderTorrent]:
|
||||
"""查询下载中任务,并附加对应下载历史的媒体与用户信息。"""
|
||||
torrents = self._list_torrents(
|
||||
downloader=name,
|
||||
status=TorrentStatus.DOWNLOADING,
|
||||
)
|
||||
if not torrents:
|
||||
return []
|
||||
history_map = self._get_history_by_hashes(
|
||||
[torrent.hash for torrent in torrents if torrent.hash]
|
||||
)
|
||||
for torrent in torrents:
|
||||
history = history_map.get(torrent.hash)
|
||||
if not history:
|
||||
continue
|
||||
torrent.media = {
|
||||
"media_source": history.media_source,
|
||||
"media_id": history.media_id,
|
||||
"type": history.type,
|
||||
"title": history.title,
|
||||
"season": history.seasons,
|
||||
"episode": history.episodes,
|
||||
"image": history.poster,
|
||||
"poster": history.poster,
|
||||
"backdrop": history.image,
|
||||
}
|
||||
torrent.site_name = history.torrent_site
|
||||
torrent.userid = history.userid
|
||||
torrent.username = history.username
|
||||
return torrents
|
||||
|
||||
def set_downloading(
|
||||
self,
|
||||
hash_str: str,
|
||||
operation: str,
|
||||
name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""按 start/stop 操作控制单个下载任务。"""
|
||||
if operation == "start":
|
||||
return self._start_torrents(hashs=[hash_str], downloader=name)
|
||||
if operation == "stop":
|
||||
return self._stop_torrents(hashs=[hash_str], downloader=name)
|
||||
return False
|
||||
|
||||
def remove_downloading(
|
||||
self,
|
||||
hash_str: str,
|
||||
name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""删除单个下载任务。"""
|
||||
return self._remove_torrents(hashs=[hash_str], downloader=name)
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.runtime.extensions.service_registry import ServiceBaseHelper
|
||||
from app.schemas import DownloaderConf, ServiceInfo
|
||||
from app.schemas.system import DownloaderConf
|
||||
from app.schemas.system import ServiceInfo
|
||||
from app.schemas.types import SystemConfigKey, ModuleType
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from app.runtime.config import settings
|
||||
from app.domain.metainfo import MetaInfoPath
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import EpisodeFormatRule, FileItem
|
||||
from app.schemas.transfer import EpisodeFormatRule
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+159
-2
@@ -1,4 +1,6 @@
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Protocol, Union
|
||||
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
@@ -9,7 +11,8 @@ 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 import FileItem, TransferInfo
|
||||
from app.schemas.workflow import FileItem
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING
|
||||
|
||||
# 失败重试次数的合法区间。下界为 1:一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)
|
||||
@@ -25,6 +28,160 @@ FAILED_RETRY_TTL = 24 * 3600
|
||||
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HistoryMutationResult:
|
||||
"""描述历史记录维护操作是否成功及兼容提示。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class DownloadHistoryMutationRepository(Protocol):
|
||||
"""下载历史删除用例需要的最小持久化端口。"""
|
||||
|
||||
def stage_delete_history(self, history_id: int) -> None:
|
||||
"""暂存下载历史删除。"""
|
||||
...
|
||||
|
||||
|
||||
class TransferHistoryMutationRepository(Protocol):
|
||||
"""整理历史删除与清理用例需要的最小持久化端口。"""
|
||||
|
||||
def get(self, history_id: int) -> Optional[Any]:
|
||||
"""读取整理历史。"""
|
||||
...
|
||||
|
||||
def stage_delete(self, history_id: int) -> None:
|
||||
"""暂存整理历史删除。"""
|
||||
...
|
||||
|
||||
def stage_truncate(self) -> None:
|
||||
"""暂存全部整理历史删除。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadFileMutationRepository(Protocol):
|
||||
"""整理历史删除时关联下载文件状态更新端口。"""
|
||||
|
||||
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
|
||||
"""暂存下载文件删除状态。"""
|
||||
...
|
||||
|
||||
|
||||
class HistoryUnitOfWork(Protocol):
|
||||
"""同步历史维护用例使用的事务端口。"""
|
||||
|
||||
def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryMutationCommand:
|
||||
"""统一提交下载历史删除,避免 API 直接持有数据库事务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: DownloadHistoryMutationRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
) -> None:
|
||||
"""保存下载历史持久化和事务端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def delete(self, history_id: int) -> HistoryMutationResult:
|
||||
"""暂存并提交单条下载历史删除。"""
|
||||
self._repository.stage_delete_history(history_id)
|
||||
self._commit()
|
||||
return HistoryMutationResult(True)
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交事务,失败时回滚。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class TransferHistoryMutationCommand:
|
||||
"""协调整理历史、关联文件状态和外部存储删除。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: TransferHistoryMutationRepository,
|
||||
download_repository: DownloadFileMutationRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
file_item_factory: Callable[[dict], Any],
|
||||
delete_media_file: Callable[[Any], bool],
|
||||
publish_download_file_deleted: Callable[[dict], None],
|
||||
clear_failures: Callable[[Optional[str], Optional[str]], None],
|
||||
) -> None:
|
||||
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
|
||||
self._repository = repository
|
||||
self._download_repository = download_repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._file_item_factory = file_item_factory
|
||||
self._delete_media_file = delete_media_file
|
||||
self._publish_download_file_deleted = publish_download_file_deleted
|
||||
self._clear_failures = clear_failures
|
||||
|
||||
def delete(
|
||||
self,
|
||||
history_id: int,
|
||||
*,
|
||||
delete_source: bool = False,
|
||||
delete_destination: bool = False,
|
||||
) -> HistoryMutationResult:
|
||||
"""删除整理记录,并保持源文件失败时不提交数据库变更。"""
|
||||
history = self._repository.get(history_id)
|
||||
if not history:
|
||||
return HistoryMutationResult(False, "记录不存在")
|
||||
|
||||
if delete_destination and history.dest_fileitem:
|
||||
destination = self._file_item_factory(history.dest_fileitem)
|
||||
self._delete_media_file(destination)
|
||||
|
||||
source_deleted = False
|
||||
if delete_source and history.src_fileitem:
|
||||
source = self._file_item_factory(history.src_fileitem)
|
||||
if not self._delete_media_file(source):
|
||||
return HistoryMutationResult(False, f"{source.path} 删除失败")
|
||||
self._download_repository.stage_delete_file_by_fullpath(
|
||||
Path(source.path).as_posix()
|
||||
)
|
||||
source_deleted = True
|
||||
|
||||
self._repository.stage_delete(history_id)
|
||||
self._commit()
|
||||
if source_deleted:
|
||||
self._publish_download_file_deleted({
|
||||
"src": history.src,
|
||||
"hash": history.download_hash,
|
||||
})
|
||||
self._clear_failures(history.src, history.src_storage)
|
||||
return HistoryMutationResult(True)
|
||||
|
||||
def truncate(self) -> HistoryMutationResult:
|
||||
"""在单一事务中清空全部整理历史。"""
|
||||
self._repository.stage_truncate()
|
||||
self._commit()
|
||||
return HistoryMutationResult(True)
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class HistoryGateAction:
|
||||
"""
|
||||
整理历史查重闸的判定结果。
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""应用级数据维护用例。
|
||||
|
||||
本模块拥有保留期、批次循环、进度和部分失败汇总语义。具体数据库表如何删除由
|
||||
``CleanupRepository`` 端口提供,调度器只负责触发用例。
|
||||
"""
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
CleanupProgress = Callable[..., None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CleanupPolicy:
|
||||
"""描述一次数据维护运行使用的总开关和各表保留期。"""
|
||||
|
||||
enabled: bool
|
||||
message_days: int
|
||||
download_history_days: int
|
||||
site_userdata_days: int
|
||||
transfer_history_days: int
|
||||
download_failure_days: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CleanupPlan:
|
||||
"""描述单张表的保留期、截止点和批量删除动作。"""
|
||||
|
||||
name: str
|
||||
retention_days: int
|
||||
cutoff: str
|
||||
delete_batch: Callable[[Any], int]
|
||||
|
||||
|
||||
class CleanupRepository(Protocol):
|
||||
"""数据维护用例需要的最小持久化端口。"""
|
||||
|
||||
def session(self) -> ContextManager[Any]:
|
||||
"""返回一次维护运行共用的数据库会话上下文。"""
|
||||
...
|
||||
|
||||
def delete_messages(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的消息。"""
|
||||
...
|
||||
|
||||
def delete_download_history(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的下载历史。"""
|
||||
...
|
||||
|
||||
def delete_download_orphans(self, db: Any, limit: int) -> int:
|
||||
"""删除已经失去父下载历史的文件记录。"""
|
||||
...
|
||||
|
||||
def delete_site_userdata(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止日期的站点用户数据快照。"""
|
||||
...
|
||||
|
||||
def delete_transfer_history(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除早于截止时间的整理历史。"""
|
||||
...
|
||||
|
||||
def delete_download_failures(self, db: Any, cutoff: str, limit: int) -> int:
|
||||
"""删除已经过期的下载失败冷却记录。"""
|
||||
...
|
||||
|
||||
|
||||
class DataCleanupService:
|
||||
"""按配置执行分批数据清理并生成兼容报告。"""
|
||||
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: CleanupRepository,
|
||||
policy_reader: Callable[[], CleanupPolicy],
|
||||
clock: Callable[[], datetime] = datetime.now,
|
||||
) -> None:
|
||||
"""保存持久化端口、动态配置读取器和可测试时钟。"""
|
||||
self._repository = repository
|
||||
self._policy_reader = policy_reader
|
||||
self._clock = clock
|
||||
|
||||
def execute(
|
||||
self,
|
||||
batch_size: Optional[int] = None,
|
||||
progress_callback: Optional[CleanupProgress] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行全部清理计划,保持旧调度入口的报告和异常语义。"""
|
||||
started_at = self._clock()
|
||||
normalized_batch_size = batch_size or self.DEFAULT_BATCH_SIZE
|
||||
if normalized_batch_size <= 0:
|
||||
normalized_batch_size = self.DEFAULT_BATCH_SIZE
|
||||
policy = self._policy_reader()
|
||||
report: Dict[str, Any] = {
|
||||
"started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"batch_size": normalized_batch_size,
|
||||
"enabled": policy.enabled,
|
||||
"tables": {},
|
||||
"total_deleted": 0,
|
||||
}
|
||||
if not policy.enabled:
|
||||
report["skipped_reason"] = "disabled"
|
||||
logger.info("数据表清理总开关未开启,跳过执行")
|
||||
return report
|
||||
|
||||
plans = self._build_plans(
|
||||
policy=policy,
|
||||
started_at=started_at,
|
||||
batch_size=normalized_batch_size,
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(value=0, text="开始清理数据表 ...")
|
||||
|
||||
errors: list[str] = []
|
||||
with self._repository.session() as db:
|
||||
for plan_index, plan in enumerate(plans):
|
||||
self._execute_plan(
|
||||
db=db,
|
||||
plan=plan,
|
||||
plan_index=plan_index,
|
||||
total_plans=len(plans),
|
||||
report=report,
|
||||
errors=errors,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
if errors:
|
||||
report["errors"] = errors
|
||||
logger.error(
|
||||
f"数据表清理部分失败:{json.dumps(report, ensure_ascii=False)}"
|
||||
)
|
||||
raise RuntimeError(";".join(errors))
|
||||
|
||||
logger.info(f"数据表清理完成:{json.dumps(report, ensure_ascii=False)}")
|
||||
return report
|
||||
|
||||
def _execute_plan(
|
||||
self,
|
||||
*,
|
||||
db: Any,
|
||||
plan: CleanupPlan,
|
||||
plan_index: int,
|
||||
total_plans: int,
|
||||
report: Dict[str, Any],
|
||||
errors: list[str],
|
||||
progress_callback: Optional[CleanupProgress],
|
||||
) -> None:
|
||||
"""执行单表计划并把成功、跳过或失败状态写入总报告。"""
|
||||
if plan.retention_days <= 0:
|
||||
report["tables"][plan.name] = {
|
||||
"deleted": 0,
|
||||
"batches": 0,
|
||||
"cutoff": None,
|
||||
"retention_days": plan.retention_days,
|
||||
"skipped": True,
|
||||
"reason": "retention_days<=0",
|
||||
}
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=(plan_index + 1) / total_plans * 100,
|
||||
text=f"数据表 {plan.name} 跳过清理",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=plan_index / total_plans * 100,
|
||||
text=f"正在清理数据表 {plan.name} ...",
|
||||
)
|
||||
table_report = self._cleanup_in_batches(
|
||||
db=db,
|
||||
table_name=plan.name,
|
||||
delete_batch=plan.delete_batch,
|
||||
)
|
||||
table_report["cutoff"] = plan.cutoff
|
||||
table_report["retention_days"] = plan.retention_days
|
||||
report["tables"][plan.name] = table_report
|
||||
report["total_deleted"] += table_report["deleted"]
|
||||
except Exception as err:
|
||||
errors.append(f"{plan.name}: {str(err)}")
|
||||
logger.error(f"数据表 {plan.name} 清理失败:{str(err)}")
|
||||
report["tables"][plan.name] = {
|
||||
"deleted": 0,
|
||||
"batches": 0,
|
||||
"cutoff": plan.cutoff,
|
||||
"retention_days": plan.retention_days,
|
||||
"error": str(err),
|
||||
}
|
||||
finally:
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=(plan_index + 1) / total_plans * 100,
|
||||
text=f"数据表 {plan.name} 清理处理完成",
|
||||
)
|
||||
|
||||
def _build_plans(
|
||||
self,
|
||||
*,
|
||||
policy: CleanupPolicy,
|
||||
started_at: datetime,
|
||||
batch_size: int,
|
||||
) -> list[CleanupPlan]:
|
||||
"""把一次动态配置快照转换为固定顺序的清理计划。"""
|
||||
message_cutoff = self._cutoff(started_at, policy.message_days, "%Y-%m-%d")
|
||||
download_history_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.download_history_days,
|
||||
"%Y-%m-%d",
|
||||
)
|
||||
site_userdata_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.site_userdata_days,
|
||||
"%Y-%m-%d",
|
||||
)
|
||||
transfer_history_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.transfer_history_days,
|
||||
"%Y-%m-%d",
|
||||
)
|
||||
download_failure_cutoff = self._cutoff(
|
||||
started_at,
|
||||
policy.download_failure_days,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
return [
|
||||
CleanupPlan(
|
||||
"message",
|
||||
policy.message_days,
|
||||
message_cutoff,
|
||||
lambda db: self._repository.delete_messages(
|
||||
db, message_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"downloadhistory",
|
||||
policy.download_history_days,
|
||||
download_history_cutoff,
|
||||
lambda db: self._repository.delete_download_history(
|
||||
db, download_history_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"downloadfiles",
|
||||
policy.download_history_days,
|
||||
"follow-parent-history",
|
||||
lambda db: self._repository.delete_download_orphans(db, batch_size),
|
||||
),
|
||||
CleanupPlan(
|
||||
"siteuserdata",
|
||||
policy.site_userdata_days,
|
||||
site_userdata_cutoff,
|
||||
lambda db: self._repository.delete_site_userdata(
|
||||
db, site_userdata_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"transferhistory",
|
||||
policy.transfer_history_days,
|
||||
transfer_history_cutoff,
|
||||
lambda db: self._repository.delete_transfer_history(
|
||||
db, transfer_history_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
CleanupPlan(
|
||||
"downloadfailure",
|
||||
policy.download_failure_days,
|
||||
download_failure_cutoff,
|
||||
lambda db: self._repository.delete_download_failures(
|
||||
db, download_failure_cutoff, batch_size
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_in_batches(
|
||||
*,
|
||||
db: Any,
|
||||
table_name: str,
|
||||
delete_batch: Callable[[Any], int],
|
||||
) -> Dict[str, int]:
|
||||
"""循环执行单表分批删除,直到持久化端口返回零。"""
|
||||
total_deleted = 0
|
||||
batches = 0
|
||||
while True:
|
||||
deleted = delete_batch(db) or 0
|
||||
if deleted <= 0:
|
||||
break
|
||||
batches += 1
|
||||
total_deleted += deleted
|
||||
logger.info(
|
||||
f"数据表 {table_name} 清理第 {batches} 批完成,删除 {deleted} 条记录"
|
||||
)
|
||||
return {"deleted": total_deleted, "batches": batches}
|
||||
|
||||
@staticmethod
|
||||
def _cutoff(started_at: datetime, retention_days: int, pattern: str) -> str:
|
||||
"""按兼容格式计算一个清理截止时间。"""
|
||||
return (started_at - timedelta(days=retention_days)).strftime(pattern)
|
||||
|
||||
|
||||
def read_cleanup_policy() -> CleanupPolicy:
|
||||
"""读取并规范化当前数据清理配置,单次运行期间保持快照一致。"""
|
||||
return CleanupPolicy(
|
||||
enabled=bool(settings.DATA_CLEANUP_ENABLE),
|
||||
message_days=_normalize_days(settings.DATA_CLEANUP_MESSAGE_DAYS),
|
||||
download_history_days=_normalize_days(
|
||||
settings.DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS
|
||||
),
|
||||
site_userdata_days=_normalize_days(settings.DATA_CLEANUP_SITE_USERDATA_DAYS),
|
||||
transfer_history_days=_normalize_days(
|
||||
settings.DATA_CLEANUP_TRANSFER_HISTORY_DAYS
|
||||
),
|
||||
download_failure_days=_normalize_days(
|
||||
settings.DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_cleanup_service() -> DataCleanupService:
|
||||
"""在应用边界组装默认数据库适配器,供兼容调度门面触发。"""
|
||||
return DataCleanupService(
|
||||
repository=DatabaseCleanupRepository(session_factory=SessionFactory),
|
||||
policy_reader=read_cleanup_policy,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_days(retention_days: Any) -> int:
|
||||
"""把配置保留期规范为非负整数,非法值按关闭单表清理处理。"""
|
||||
try:
|
||||
normalized_days = int(retention_days or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(normalized_days, 0)
|
||||
@@ -2,11 +2,12 @@ import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any, Optional
|
||||
|
||||
from app import schemas
|
||||
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.schemas import MediaServerConf, ServiceInfo
|
||||
from app.schemas.system import MediaServerConf
|
||||
from app.schemas.system import ServiceInfo
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MediaSource,
|
||||
@@ -68,7 +69,7 @@ class MediaServerIdentityHelper:
|
||||
@classmethod
|
||||
def is_compatible(
|
||||
cls,
|
||||
item: schemas.MediaServerItem,
|
||||
item: _SchemaMediaServerItem,
|
||||
media_source: Optional[MediaSource | str],
|
||||
media_id: Optional[str],
|
||||
) -> bool:
|
||||
@@ -196,7 +197,7 @@ class MusicMediaServerHelper:
|
||||
def item_matches(
|
||||
cls,
|
||||
mediainfo: MusicInfo,
|
||||
item: schemas.MediaServerItem,
|
||||
item: _SchemaMediaServerItem,
|
||||
) -> bool:
|
||||
"""校验媒体库条目是否精确对应单曲,或完整覆盖目标专辑。"""
|
||||
note = item.note if isinstance(item.note, Mapping) else {}
|
||||
@@ -237,8 +238,8 @@ class MusicMediaServerHelper:
|
||||
def find_match(
|
||||
cls,
|
||||
mediainfo: MusicInfo,
|
||||
items: Optional[Iterable[schemas.MediaServerItem]],
|
||||
) -> Optional[schemas.MediaServerItem]:
|
||||
items: Optional[Iterable[_SchemaMediaServerItem]],
|
||||
) -> Optional[_SchemaMediaServerItem]:
|
||||
"""返回首个满足单曲精确匹配或整专完整性要求的媒体库条目。"""
|
||||
return next(
|
||||
(item for item in items or [] if item and cls.item_matches(mediainfo, item)),
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple, Union
|
||||
|
||||
from app.schemas import Message
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.notification import ChannelCapabilityManager
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from app.application.messaging.interaction import InteractionContext, MessageGateway
|
||||
from app.runtime.events import EventManager
|
||||
from app.schemas import Message
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import EventType, NotificationChannel
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""消息入口的用户会话状态用例。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, MutableMapping, Optional, Union
|
||||
|
||||
|
||||
UserId = Union[str, int]
|
||||
SessionEntry = tuple[str, datetime]
|
||||
ExpiredSessionHandler = Callable[[str, UserId], None]
|
||||
Clock = Callable[[], datetime]
|
||||
SessionIdFactory = Callable[[UserId, datetime], str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionResolution:
|
||||
"""描述用户会话解析结果及是否复用了旧会话。"""
|
||||
|
||||
session_id: str
|
||||
reused: bool
|
||||
inactive_minutes: float = 0.0
|
||||
|
||||
|
||||
class MessageSessionService:
|
||||
"""管理消息用户到 Agent 会话的绑定、复用和过期清理。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sessions: MutableMapping[UserId, SessionEntry],
|
||||
timeout_minutes: int,
|
||||
expired_handler: ExpiredSessionHandler,
|
||||
clock: Clock = datetime.now,
|
||||
session_id_factory: Optional[SessionIdFactory] = None,
|
||||
) -> None:
|
||||
"""保存共享会话映射和由 Chain 提供的 Agent 清理端口。"""
|
||||
self._sessions = sessions
|
||||
self._timeout = timedelta(minutes=timeout_minutes)
|
||||
self._expired_handler = expired_handler
|
||||
self._clock = clock
|
||||
self._session_id_factory = session_id_factory or self._default_session_id
|
||||
|
||||
@staticmethod
|
||||
def _default_session_id(user_id: UserId, now: datetime) -> str:
|
||||
"""按历史格式生成新的用户会话 ID。"""
|
||||
return f"user_{user_id}_{int(now.timestamp())}"
|
||||
|
||||
def cleanup(self, now: Optional[datetime] = None) -> None:
|
||||
"""移除超时绑定,并通知拥有者释放对应 Agent 会话。"""
|
||||
current_time = now or self._clock()
|
||||
for user_id, (session_id, last_time) in list(self._sessions.items()):
|
||||
if current_time - last_time <= self._timeout:
|
||||
continue
|
||||
self._sessions.pop(user_id, None)
|
||||
self._expired_handler(session_id, user_id)
|
||||
|
||||
def resolve(self, user_id: UserId) -> SessionResolution:
|
||||
"""复用有效绑定或为用户创建新会话。"""
|
||||
current_time = self._clock()
|
||||
self.cleanup(current_time)
|
||||
current = self._sessions.get(user_id)
|
||||
if current:
|
||||
session_id, last_time = current
|
||||
inactive = current_time - last_time
|
||||
if inactive <= self._timeout:
|
||||
self._sessions[user_id] = (session_id, current_time)
|
||||
return SessionResolution(
|
||||
session_id=session_id,
|
||||
reused=True,
|
||||
inactive_minutes=inactive.total_seconds() / 60,
|
||||
)
|
||||
|
||||
session_id = self._session_id_factory(user_id, current_time)
|
||||
self._sessions[user_id] = (session_id, current_time)
|
||||
return SessionResolution(session_id=session_id, reused=False)
|
||||
|
||||
def bind(self, user_id: UserId, session_id: str) -> None:
|
||||
"""绑定指定会话,并在替换时释放旧会话。"""
|
||||
current = self._sessions.get(user_id)
|
||||
if current and current[0] != session_id:
|
||||
self._expired_handler(current[0], user_id)
|
||||
self._sessions[user_id] = (session_id, self._clock())
|
||||
|
||||
def clear(self, user_id: UserId) -> Optional[str]:
|
||||
"""清除用户绑定并返回被移除的会话 ID。"""
|
||||
current = self._sessions.pop(user_id, None)
|
||||
return current[0] if current else None
|
||||
|
||||
def get(self, user_id: UserId) -> Optional[SessionEntry]:
|
||||
"""读取用户当前会话绑定,不改变最后活动时间。"""
|
||||
return self._sessions.get(user_id)
|
||||
@@ -15,7 +15,7 @@ from app.application.messaging.interaction import (
|
||||
update_or_post_message,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.schemas import Message
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.application.messaging.interaction import (
|
||||
supports_interaction_buttons,
|
||||
update_or_post_message,
|
||||
)
|
||||
from app.schemas import Message
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.application.messaging.interaction import (
|
||||
)
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.schemas import Message
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.types import NotificationChannel, MediaType
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""音乐应用服务。"""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""多来源音乐目录搜索应用服务。"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
from app.domain.context import MusicInfo
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.types import MediaSource, MediaSourceSelection
|
||||
|
||||
|
||||
class MusicCatalogService:
|
||||
"""编排音乐来源选择、容错搜索和候选归一化。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_resolver: Callable[[MediaSource], Any],
|
||||
warning: Callable[[str], None],
|
||||
primary_source: MediaSource = MediaSource.MusicBrainz,
|
||||
) -> None:
|
||||
"""注入来源解析器、告警输出和默认音乐来源。"""
|
||||
self._source_resolver = source_resolver
|
||||
self._warning = warning
|
||||
self._primary_source = primary_source
|
||||
|
||||
def search_sources(
|
||||
self,
|
||||
media_source: Optional[MediaSourceSelection],
|
||||
) -> list[MediaSource]:
|
||||
"""解析有序音乐来源,保留合法插件扩展来源并去重。"""
|
||||
if not media_source:
|
||||
return [self._primary_source]
|
||||
raw_sources = (
|
||||
(media_source,)
|
||||
if isinstance(media_source, MediaSource)
|
||||
else media_source
|
||||
)
|
||||
sources = []
|
||||
for raw_source in raw_sources:
|
||||
source = normalize_media_source(raw_source)
|
||||
if source and source not in sources:
|
||||
sources.append(source)
|
||||
return sources
|
||||
|
||||
@staticmethod
|
||||
def normalize_candidates(
|
||||
candidates: Optional[Iterable[MusicInfo | dict[str, Any]]],
|
||||
limit: Optional[int] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""标准化并按来源身份或元数据去重音乐候选。"""
|
||||
results = []
|
||||
identities = set()
|
||||
for candidate in candidates or []:
|
||||
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
|
||||
if info.media_source and info.media_id:
|
||||
identity = (
|
||||
"id",
|
||||
str(info.media_source).casefold(),
|
||||
str(info.music_type).casefold(),
|
||||
str(info.media_id).casefold(),
|
||||
)
|
||||
else:
|
||||
identity = (
|
||||
"metadata",
|
||||
str(info.music_type).casefold(),
|
||||
MetaMusic.compact_text(info.title),
|
||||
MetaMusic.compact_text(info.artist),
|
||||
MetaMusic.compact_text(info.album),
|
||||
)
|
||||
if identity in identities:
|
||||
continue
|
||||
identities.add(identity)
|
||||
results.append(info)
|
||||
if limit and len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
|
||||
meta = MetaMusic.parse_query(query)
|
||||
candidates = []
|
||||
for source in self.search_sources(media_source):
|
||||
chain = self._source_resolver(source)
|
||||
if not chain:
|
||||
continue
|
||||
try:
|
||||
candidates.extend(chain.search_music(meta, limit=limit))
|
||||
except Exception as error:
|
||||
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
|
||||
return self.normalize_candidates(candidates, limit=limit)
|
||||
|
||||
async def async_search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
|
||||
meta = MetaMusic.parse_query(query)
|
||||
searches = []
|
||||
for source in self.search_sources(media_source):
|
||||
chain = self._source_resolver(source)
|
||||
if chain:
|
||||
searches.append(self._async_search_source(chain, source, meta, limit))
|
||||
source_results = await asyncio.gather(*searches) if searches else []
|
||||
return self.normalize_candidates(
|
||||
[candidate for results in source_results for candidate in results],
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def _async_search_source(
|
||||
self,
|
||||
chain: Any,
|
||||
source: MediaSource,
|
||||
meta: MetaMusic,
|
||||
limit: int,
|
||||
) -> list[MusicInfo]:
|
||||
"""异步搜索单个来源,并把异常降级为空候选。"""
|
||||
try:
|
||||
return await chain.async_search_music(meta, limit=limit)
|
||||
except Exception as error:
|
||||
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
|
||||
return []
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.runtime.extensions.service_registry import ServiceBaseHelper
|
||||
from app.schemas import NotificationConf, ServiceInfo
|
||||
from app.schemas.system import NotificationConf
|
||||
from app.schemas.system import ServiceInfo
|
||||
from app.schemas.types import ModuleType, SystemConfigKey
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""插件应用端口与用例。"""
|
||||
@@ -0,0 +1,275 @@
|
||||
"""插件市场目录应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
|
||||
AsyncMarketLoader = Callable[
|
||||
[str, Optional[str], bool],
|
||||
Awaitable[Optional[dict[str, dict]]],
|
||||
]
|
||||
PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
|
||||
ProgressCallback = Callable[..., Any]
|
||||
|
||||
|
||||
class PluginCatalogService:
|
||||
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
market_loader: MarketLoader,
|
||||
async_market_loader: AsyncMarketLoader,
|
||||
installed_plugins_provider: Callable[[], list[str]],
|
||||
plugin_mapper: PluginMapper,
|
||||
is_local_repo: Callable[[Optional[str]], bool],
|
||||
version_compare: Callable[[str, str, str], bool],
|
||||
warning: Callable[[str], Any],
|
||||
error: Callable[[str], Any],
|
||||
) -> None:
|
||||
"""保存市场读取、插件映射和版本比较端口。"""
|
||||
self._market_loader = market_loader
|
||||
self._async_market_loader = async_market_loader
|
||||
self._installed_plugins_provider = installed_plugins_provider
|
||||
self._plugin_mapper = plugin_mapper
|
||||
self._is_local_repo = is_local_repo
|
||||
self._version_compare = version_compare
|
||||
self._warning = warning
|
||||
self._error = error
|
||||
|
||||
def load(
|
||||
self,
|
||||
market: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> list[Any]:
|
||||
"""同步读取并映射指定市场和插件代际。"""
|
||||
if not market:
|
||||
return []
|
||||
online_plugins = self._market_loader(market, package_version, force)
|
||||
if online_plugins is None:
|
||||
self._warning(
|
||||
f"获取{package_version if package_version else ''}插件库失败:"
|
||||
f"{market},请检查 GitHub 网络连接"
|
||||
)
|
||||
return []
|
||||
return self._map_plugins(online_plugins, market, package_version)
|
||||
|
||||
async def async_load(
|
||||
self,
|
||||
market: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> list[Any]:
|
||||
"""异步读取并映射指定市场和插件代际。"""
|
||||
if not market:
|
||||
return []
|
||||
online_plugins = await self._async_market_loader(
|
||||
market,
|
||||
package_version,
|
||||
force,
|
||||
)
|
||||
if online_plugins is None:
|
||||
self._warning(
|
||||
f"获取{package_version if package_version else ''}插件库失败:"
|
||||
f"{market},请检查 GitHub 网络连接"
|
||||
)
|
||||
return []
|
||||
return self._map_plugins(online_plugins, market, package_version)
|
||||
|
||||
def collect(
|
||||
self,
|
||||
*,
|
||||
markets: list[str],
|
||||
compatible_flags: list[str],
|
||||
force: bool,
|
||||
loader: Callable[[str, Optional[str], bool], list[Any]],
|
||||
) -> list[Any]:
|
||||
"""并发读取多个市场和代际,并按稳定优先级合并。"""
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures_meta: dict[
|
||||
concurrent.futures.Future,
|
||||
tuple[int, bool, int],
|
||||
] = {}
|
||||
for market_index, market in enumerate(markets):
|
||||
base_future = executor.submit(loader, market, None, force)
|
||||
futures_meta[base_future] = (market_index, False, 0)
|
||||
for flag_priority, flag in enumerate(compatible_flags):
|
||||
higher_future = executor.submit(loader, market, flag, force)
|
||||
futures_meta[higher_future] = (
|
||||
market_index,
|
||||
True,
|
||||
flag_priority,
|
||||
)
|
||||
|
||||
collected = []
|
||||
for future in concurrent.futures.as_completed(futures_meta):
|
||||
plugins = future.result()
|
||||
market_index, is_higher, flag_priority = futures_meta[future]
|
||||
collected.append((
|
||||
market_index,
|
||||
is_higher,
|
||||
flag_priority,
|
||||
plugins or [],
|
||||
))
|
||||
|
||||
collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2]))
|
||||
higher_plugins = []
|
||||
base_plugins = []
|
||||
for _market_index, is_higher, _flag_priority, plugins in collected:
|
||||
(higher_plugins if is_higher else base_plugins).extend(plugins)
|
||||
return self.merge(higher_plugins, base_plugins, markets)
|
||||
|
||||
async def async_collect(
|
||||
self,
|
||||
*,
|
||||
markets: list[str],
|
||||
compatible_flags: list[str],
|
||||
force: bool,
|
||||
loader: Callable[
|
||||
[str, Optional[str], bool],
|
||||
Awaitable[list[Any]],
|
||||
],
|
||||
progress_callback: Optional[ProgressCallback] = None,
|
||||
) -> list[Any]:
|
||||
"""异步读取多个市场和代际,并持续报告稳定进度。"""
|
||||
async def fetch(
|
||||
market: str,
|
||||
package_version: Optional[str],
|
||||
result_version: str,
|
||||
task_index: int,
|
||||
) -> tuple[int, str, list[Any]]:
|
||||
"""读取一个市场代际并保留创建时的稳定任务序号。"""
|
||||
plugins = await loader(market, package_version, force)
|
||||
return task_index, result_version, plugins or []
|
||||
|
||||
tasks = []
|
||||
for market in markets:
|
||||
tasks.append(asyncio.create_task(
|
||||
fetch(market, None, "base_version", len(tasks))
|
||||
))
|
||||
for flag in compatible_flags:
|
||||
tasks.append(asyncio.create_task(
|
||||
fetch(market, flag, "higher_version", len(tasks))
|
||||
))
|
||||
|
||||
higher_plugins = []
|
||||
base_plugins = []
|
||||
if tasks:
|
||||
total_tasks = len(tasks)
|
||||
finished_tasks = 0
|
||||
task_results = {}
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=0,
|
||||
text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...",
|
||||
data={"total": total_tasks, "finished": 0},
|
||||
)
|
||||
for completed_task in asyncio.as_completed(tasks):
|
||||
try:
|
||||
task_index, version, plugins = await completed_task
|
||||
task_results[task_index] = (version, plugins)
|
||||
except Exception as err:
|
||||
self._error(f"获取插件市场数据失败:{str(err)}")
|
||||
finished_tasks += 1
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
value=finished_tasks / total_tasks * 100,
|
||||
text=(
|
||||
f"插件市场请求({finished_tasks}/{total_tasks})"
|
||||
"处理完成"
|
||||
),
|
||||
data={"total": total_tasks, "finished": finished_tasks},
|
||||
)
|
||||
for task_index in sorted(task_results):
|
||||
version, plugins = task_results[task_index]
|
||||
(higher_plugins if version == "higher_version" else base_plugins).extend(
|
||||
plugins
|
||||
)
|
||||
|
||||
result = self.merge(higher_plugins, base_plugins, markets)
|
||||
if progress_callback:
|
||||
progress_callback(value=100, text="插件市场缓存刷新完成")
|
||||
return result
|
||||
|
||||
def merge(
|
||||
self,
|
||||
higher_plugins: list[Any],
|
||||
base_plugins: list[Any],
|
||||
markets: list[str],
|
||||
) -> list[Any]:
|
||||
"""按代际、来源顺序和版本合并插件目录。"""
|
||||
all_plugins = list(higher_plugins)
|
||||
higher_keys = {
|
||||
f"{plugin.id}{plugin.plugin_version}"
|
||||
for plugin in higher_plugins
|
||||
}
|
||||
all_plugins.extend(
|
||||
plugin
|
||||
for plugin in base_plugins
|
||||
if f"{plugin.id}{plugin.plugin_version}" not in higher_keys
|
||||
)
|
||||
|
||||
def repo_order(plugin: Any) -> int:
|
||||
"""本地来源排在远程市场之后,远程来源保持配置顺序。"""
|
||||
if self._is_local_repo(plugin.repo_url):
|
||||
return len(markets) + 1
|
||||
if plugin.repo_url in markets:
|
||||
return markets.index(plugin.repo_url)
|
||||
return len(markets)
|
||||
|
||||
deduplicated = {}
|
||||
for plugin in sorted(all_plugins, key=repo_order):
|
||||
key = f"{plugin.id}{plugin.plugin_version}"
|
||||
exists = deduplicated.get(key)
|
||||
if not exists or (
|
||||
self._is_local_repo(exists.repo_url)
|
||||
and not self._is_local_repo(plugin.repo_url)
|
||||
):
|
||||
deduplicated[key] = plugin
|
||||
|
||||
result_by_id = {}
|
||||
for plugin in sorted(deduplicated.values(), key=repo_order):
|
||||
exists = result_by_id.get(plugin.id)
|
||||
if not exists \
|
||||
or self._version_compare(
|
||||
plugin.plugin_version,
|
||||
">",
|
||||
exists.plugin_version,
|
||||
) \
|
||||
or (
|
||||
plugin.plugin_version == exists.plugin_version
|
||||
and self._is_local_repo(exists.repo_url)
|
||||
and not self._is_local_repo(plugin.repo_url)
|
||||
):
|
||||
result_by_id[plugin.id] = plugin
|
||||
return list(result_by_id.values())
|
||||
|
||||
def _map_plugins(
|
||||
self,
|
||||
online_plugins: dict[str, dict],
|
||||
market: str,
|
||||
package_version: Optional[str],
|
||||
) -> list[Any]:
|
||||
"""把一个市场索引映射为宿主插件 DTO。"""
|
||||
installed_plugins = self._installed_plugins_provider()
|
||||
result = []
|
||||
add_time = len(online_plugins)
|
||||
for plugin_id, plugin_info in online_plugins.items():
|
||||
plugin = self._plugin_mapper(
|
||||
plugin_id,
|
||||
plugin_info,
|
||||
market,
|
||||
installed_plugins,
|
||||
add_time,
|
||||
package_version,
|
||||
)
|
||||
if plugin:
|
||||
result.append(plugin)
|
||||
add_time -= 1
|
||||
return result
|
||||
@@ -0,0 +1,59 @@
|
||||
"""插件配置保存、重置和运行态重建应用用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginConfigResult:
|
||||
"""描述插件配置写操作是否成功及提示信息。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class PluginConfigCommand:
|
||||
"""协调插件配置持久化、实例初始化和运行时注册刷新。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
save_config: Callable[[str, dict, bool], bool],
|
||||
initialize: Callable[[str, dict], Any],
|
||||
stop: Callable[[str], Any],
|
||||
delete_config: Callable[[str, bool], bool],
|
||||
delete_data: Callable[[str, bool], bool],
|
||||
reload_runtime: Callable[[str], Any],
|
||||
publish_reset: Callable[[str], Any],
|
||||
refresh_registrations: Callable[[str], Any],
|
||||
) -> None:
|
||||
"""保存插件管理 Facade 和运行时注册刷新端口。"""
|
||||
self._save_config = save_config
|
||||
self._initialize = initialize
|
||||
self._stop = stop
|
||||
self._delete_config = delete_config
|
||||
self._delete_data = delete_data
|
||||
self._reload_runtime = reload_runtime
|
||||
self._publish_reset = publish_reset
|
||||
self._refresh_registrations = refresh_registrations
|
||||
|
||||
def update(self, plugin_id: str, config: dict) -> PluginConfigResult:
|
||||
"""保存配置并按既有顺序重新初始化实例及运行时注册。"""
|
||||
if not self._save_config(plugin_id, config, False):
|
||||
return PluginConfigResult(False, "插件配置保存失败")
|
||||
self._initialize(plugin_id, config)
|
||||
self._refresh_registrations(plugin_id)
|
||||
return PluginConfigResult(True)
|
||||
|
||||
def reset(self, plugin_id: str) -> PluginConfigResult:
|
||||
"""通知插件补偿后停止实例、删除配置数据并重建运行态。"""
|
||||
self._publish_reset(plugin_id)
|
||||
self._stop(plugin_id)
|
||||
self._delete_config(plugin_id, True)
|
||||
self._delete_data(plugin_id, True)
|
||||
self._reload_runtime(plugin_id)
|
||||
self._refresh_registrations(plugin_id)
|
||||
return PluginConfigResult(True)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""插件安装应用用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
InstalledPluginsReader = Callable[[], list[str]]
|
||||
InstalledPluginsWriter = Callable[[list[str]], Awaitable[object]]
|
||||
PluginIdsProvider = Callable[[], list[str]]
|
||||
CompatibilityChecker = Callable[[str, str], Awaitable[Optional[str]]]
|
||||
PackageInstaller = Callable[
|
||||
[str, str, Optional[str], bool],
|
||||
Awaitable[tuple[bool, str]],
|
||||
]
|
||||
PackageCheckpointer = Callable[[str], Awaitable[Any]]
|
||||
PackageCheckpointAction = Callable[[Any], Awaitable[object]]
|
||||
InstallReporter = Callable[[str, Optional[str]], Awaitable[object]]
|
||||
PluginReloader = Callable[[str], Awaitable[object]]
|
||||
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallRollback:
|
||||
"""描述失败安装中各类可补偿副作用的恢复结果。"""
|
||||
|
||||
file_attempted: bool = False
|
||||
file_restored: bool = False
|
||||
installed_list_attempted: bool = False
|
||||
installed_list_restored: bool = False
|
||||
runtime_attempted: bool = False
|
||||
runtime_restored: bool = False
|
||||
registrations_attempted: bool = False
|
||||
registrations_restored: bool = False
|
||||
dependency_supported: bool = False
|
||||
errors: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginInstallResult:
|
||||
"""描述插件安装结果、失败阶段和可观察补偿状态。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
refreshed_only: bool = False
|
||||
package_installed: bool = False
|
||||
installed_list_persisted: bool = False
|
||||
runtime_reloaded: bool = False
|
||||
registrations_refreshed: bool = False
|
||||
reported: bool = False
|
||||
report_error: str = ""
|
||||
failure_stage: Optional[str] = None
|
||||
checkpoint_cleanup_error: str = ""
|
||||
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
|
||||
|
||||
|
||||
class PluginInstallCommand:
|
||||
"""协调插件检查、包事务、持久化、运行态刷新和安装上报。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
installed_plugins_reader: InstalledPluginsReader,
|
||||
installed_plugins_writer: InstalledPluginsWriter,
|
||||
plugin_ids_provider: PluginIdsProvider,
|
||||
compatibility_checker: CompatibilityChecker,
|
||||
package_installer: PackageInstaller,
|
||||
package_checkpointer: PackageCheckpointer,
|
||||
package_committer: PackageCheckpointAction,
|
||||
package_rollback: PackageCheckpointAction,
|
||||
install_reporter: InstallReporter,
|
||||
plugin_reloader: PluginReloader,
|
||||
registration_refresher: PluginRegistrationRefresher,
|
||||
) -> None:
|
||||
"""保存安装用例所需端口,不绑定数据库、网络或运行时实现。"""
|
||||
self._installed_plugins_reader = installed_plugins_reader
|
||||
self._installed_plugins_writer = installed_plugins_writer
|
||||
self._plugin_ids_provider = plugin_ids_provider
|
||||
self._compatibility_checker = compatibility_checker
|
||||
self._package_installer = package_installer
|
||||
self._package_checkpointer = package_checkpointer
|
||||
self._package_committer = package_committer
|
||||
self._package_rollback = package_rollback
|
||||
self._install_reporter = install_reporter
|
||||
self._plugin_reloader = plugin_reloader
|
||||
self._registration_refresher = registration_refresher
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: Optional[str],
|
||||
release_version: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> PluginInstallResult:
|
||||
"""执行插件安装,并在关键阶段失败时恢复可补偿状态。"""
|
||||
installed_plugins = list(self._installed_plugins_reader() or [])
|
||||
refreshed_only = not force and plugin_id in self._plugin_ids_provider()
|
||||
if refreshed_only:
|
||||
return await self._refresh_existing(
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
if not repo_url:
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message="没有传入仓库地址,无法正确安装插件,请检查配置",
|
||||
failure_stage="validation",
|
||||
)
|
||||
|
||||
try:
|
||||
checkpoint = await self._package_checkpointer(plugin_id)
|
||||
except Exception as err:
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=f"创建插件安装快照失败:{err}",
|
||||
failure_stage="package_checkpoint",
|
||||
)
|
||||
|
||||
try:
|
||||
state, message = await self._package_installer(
|
||||
plugin_id,
|
||||
repo_url,
|
||||
release_version,
|
||||
force,
|
||||
)
|
||||
except Exception as err:
|
||||
return await self._failure(
|
||||
plugin_id=plugin_id,
|
||||
original_plugins=installed_plugins,
|
||||
checkpoint=checkpoint,
|
||||
stage="package_install",
|
||||
message=str(err),
|
||||
package_installed=False,
|
||||
)
|
||||
if not state:
|
||||
return await self._failure(
|
||||
plugin_id=plugin_id,
|
||||
original_plugins=installed_plugins,
|
||||
checkpoint=checkpoint,
|
||||
stage="package_install",
|
||||
message=message,
|
||||
package_installed=False,
|
||||
)
|
||||
|
||||
installed_list_persisted = False
|
||||
if plugin_id not in installed_plugins:
|
||||
updated_plugins = [*installed_plugins, plugin_id]
|
||||
try:
|
||||
await self._installed_plugins_writer(updated_plugins)
|
||||
installed_list_persisted = True
|
||||
except Exception as err:
|
||||
return await self._failure(
|
||||
plugin_id=plugin_id,
|
||||
original_plugins=installed_plugins,
|
||||
checkpoint=checkpoint,
|
||||
stage="installed_list_persistence",
|
||||
message=str(err),
|
||||
package_installed=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
except Exception as err:
|
||||
return await self._failure(
|
||||
plugin_id=plugin_id,
|
||||
original_plugins=installed_plugins,
|
||||
checkpoint=checkpoint,
|
||||
stage="runtime_reload",
|
||||
message=str(err),
|
||||
package_installed=True,
|
||||
installed_list_persisted=installed_list_persisted,
|
||||
runtime_touched=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._registration_refresher(plugin_id)
|
||||
except Exception as err:
|
||||
return await self._failure(
|
||||
plugin_id=plugin_id,
|
||||
original_plugins=installed_plugins,
|
||||
checkpoint=checkpoint,
|
||||
stage="registration_refresh",
|
||||
message=str(err),
|
||||
package_installed=True,
|
||||
installed_list_persisted=installed_list_persisted,
|
||||
runtime_touched=True,
|
||||
registrations_touched=True,
|
||||
)
|
||||
|
||||
checkpoint_cleanup_error = ""
|
||||
try:
|
||||
await self._package_committer(checkpoint)
|
||||
except Exception as err:
|
||||
checkpoint_cleanup_error = str(err)
|
||||
|
||||
reported = False
|
||||
report_error = ""
|
||||
try:
|
||||
report_result = await self._install_reporter(plugin_id, repo_url)
|
||||
reported = report_result is not False
|
||||
if not reported:
|
||||
report_error = "安装上报未确认"
|
||||
except Exception as err:
|
||||
report_error = str(err)
|
||||
|
||||
result_message = message or "插件安装成功"
|
||||
if checkpoint_cleanup_error:
|
||||
result_message = f"{result_message};临时安装快照清理失败"
|
||||
if report_error:
|
||||
result_message = f"{result_message};安装上报失败,不影响本地安装"
|
||||
return PluginInstallResult(
|
||||
success=True,
|
||||
message=result_message,
|
||||
package_installed=True,
|
||||
installed_list_persisted=installed_list_persisted,
|
||||
runtime_reloaded=True,
|
||||
registrations_refreshed=True,
|
||||
reported=reported,
|
||||
report_error=report_error,
|
||||
checkpoint_cleanup_error=checkpoint_cleanup_error,
|
||||
)
|
||||
|
||||
async def _refresh_existing(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
repo_url: Optional[str],
|
||||
) -> PluginInstallResult:
|
||||
"""刷新已存在插件,不触碰包文件和已安装列表。"""
|
||||
if repo_url:
|
||||
compatible_message = await self._compatibility_checker(
|
||||
plugin_id,
|
||||
repo_url,
|
||||
)
|
||||
if compatible_message:
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=compatible_message,
|
||||
refreshed_only=True,
|
||||
failure_stage="compatibility",
|
||||
)
|
||||
failure_stage = "runtime_reload"
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
failure_stage = "registration_refresh"
|
||||
await self._registration_refresher(plugin_id)
|
||||
except Exception as err:
|
||||
rollback_errors = []
|
||||
runtime_restored = False
|
||||
registrations_restored = False
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
runtime_restored = True
|
||||
except Exception as rollback_err:
|
||||
rollback_errors.append(f"运行态恢复失败:{rollback_err}")
|
||||
if runtime_restored:
|
||||
try:
|
||||
await self._registration_refresher(plugin_id)
|
||||
registrations_restored = True
|
||||
except Exception as rollback_err:
|
||||
rollback_errors.append(f"路由和服务注册恢复失败:{rollback_err}")
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=f"刷新插件运行态失败:{err}",
|
||||
refreshed_only=True,
|
||||
failure_stage=failure_stage,
|
||||
rollback=PluginInstallRollback(
|
||||
runtime_attempted=True,
|
||||
runtime_restored=runtime_restored,
|
||||
registrations_attempted=True,
|
||||
registrations_restored=registrations_restored,
|
||||
errors=tuple(rollback_errors),
|
||||
),
|
||||
)
|
||||
|
||||
reported = False
|
||||
report_error = ""
|
||||
try:
|
||||
report_result = await self._install_reporter(plugin_id, repo_url)
|
||||
reported = report_result is not False
|
||||
if not reported:
|
||||
report_error = "安装上报未确认"
|
||||
except Exception as err:
|
||||
report_error = str(err)
|
||||
return PluginInstallResult(
|
||||
success=True,
|
||||
message=(
|
||||
"插件已存在,已刷新加载"
|
||||
if not report_error
|
||||
else "插件已存在,已刷新加载;安装上报失败,不影响本地刷新"
|
||||
),
|
||||
refreshed_only=True,
|
||||
runtime_reloaded=True,
|
||||
registrations_refreshed=True,
|
||||
reported=reported,
|
||||
report_error=report_error,
|
||||
)
|
||||
|
||||
async def _failure(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str,
|
||||
original_plugins: list[str],
|
||||
checkpoint: Any,
|
||||
stage: str,
|
||||
message: str,
|
||||
package_installed: bool,
|
||||
installed_list_persisted: bool = False,
|
||||
runtime_touched: bool = False,
|
||||
registrations_touched: bool = False,
|
||||
) -> PluginInstallResult:
|
||||
"""按持久化、文件、运行态顺序补偿失败安装并记录结果。"""
|
||||
errors = []
|
||||
installed_list_restored = False
|
||||
if installed_list_persisted:
|
||||
try:
|
||||
await self._installed_plugins_writer(list(original_plugins))
|
||||
installed_list_restored = True
|
||||
except Exception as err:
|
||||
errors.append(f"已安装列表恢复失败:{err}")
|
||||
|
||||
file_restored = False
|
||||
try:
|
||||
await self._package_rollback(checkpoint)
|
||||
file_restored = True
|
||||
except Exception as err:
|
||||
errors.append(f"插件文件恢复失败:{err}")
|
||||
|
||||
runtime_restored = False
|
||||
registrations_restored = False
|
||||
if runtime_touched:
|
||||
try:
|
||||
await self._plugin_reloader(plugin_id)
|
||||
runtime_restored = True
|
||||
except Exception as err:
|
||||
errors.append(f"插件运行态恢复失败:{err}")
|
||||
if runtime_restored:
|
||||
try:
|
||||
await self._registration_refresher(plugin_id)
|
||||
registrations_restored = True
|
||||
except Exception as err:
|
||||
errors.append(f"插件路由和服务注册恢复失败:{err}")
|
||||
|
||||
rollback = PluginInstallRollback(
|
||||
file_attempted=True,
|
||||
file_restored=file_restored,
|
||||
installed_list_attempted=installed_list_persisted,
|
||||
installed_list_restored=installed_list_restored,
|
||||
runtime_attempted=runtime_touched,
|
||||
runtime_restored=runtime_restored,
|
||||
registrations_attempted=runtime_touched or registrations_touched,
|
||||
registrations_restored=registrations_restored,
|
||||
dependency_supported=False,
|
||||
errors=tuple(errors),
|
||||
)
|
||||
rollback_message = []
|
||||
rollback_message.append("插件文件已恢复" if file_restored else "插件文件恢复失败")
|
||||
if installed_list_persisted:
|
||||
rollback_message.append(
|
||||
"已安装列表已恢复"
|
||||
if installed_list_restored
|
||||
else "已安装列表恢复失败"
|
||||
)
|
||||
if runtime_touched:
|
||||
rollback_message.append(
|
||||
"旧运行态已恢复" if runtime_restored else "旧运行态恢复失败"
|
||||
)
|
||||
rollback_message.append(
|
||||
"旧路由和服务注册已恢复"
|
||||
if registrations_restored
|
||||
else "旧路由和服务注册恢复失败"
|
||||
)
|
||||
rollback_message.append("Python依赖变更不支持自动回滚")
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=f"{message};{';'.join(rollback_message)}",
|
||||
package_installed=package_installed,
|
||||
installed_list_persisted=installed_list_persisted,
|
||||
failure_stage=stage,
|
||||
rollback=rollback,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""动态插件路由应用端口。"""
|
||||
|
||||
from typing import Optional, Protocol
|
||||
|
||||
|
||||
class DynamicRouteRegistry(Protocol):
|
||||
"""插件生命周期操作动态 HTTP 路由所需的最小端口。"""
|
||||
|
||||
def update(self, plugin_id: Optional[str], action: str) -> None:
|
||||
"""新增或移除指定插件的动态路由。"""
|
||||
...
|
||||
|
||||
def remove(self, plugin_id: str) -> bool:
|
||||
"""移除指定插件的全部动态路由。"""
|
||||
...
|
||||
+23
-71
@@ -11,8 +11,9 @@ FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
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
|
||||
@@ -45,7 +46,21 @@ def get_api_app() -> FastAPI:
|
||||
return _api_app
|
||||
|
||||
|
||||
def register_plugin_api(plugin_id: Optional[str] = None):
|
||||
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 register_plugin_api(plugin_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
动态注册插件 API
|
||||
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
|
||||
@@ -53,7 +68,7 @@ def register_plugin_api(plugin_id: Optional[str] = None):
|
||||
_update_plugin_api_routes(plugin_id, action="add")
|
||||
|
||||
|
||||
def remove_plugin_api(plugin_id: str):
|
||||
def remove_plugin_api(plugin_id: str) -> None:
|
||||
"""
|
||||
动态移除单个插件的 API
|
||||
:param plugin_id: 插件 ID
|
||||
@@ -61,55 +76,14 @@ def remove_plugin_api(plugin_id: str):
|
||||
_update_plugin_api_routes(plugin_id, action="remove")
|
||||
|
||||
|
||||
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||
def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None:
|
||||
"""
|
||||
插件 API 路由注册和移除
|
||||
:param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件
|
||||
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
|
||||
:param action: "add" 或 "remove",决定是添加还是移除路由
|
||||
"""
|
||||
if action not in {"add", "remove"}:
|
||||
raise ValueError("Action must be 'add' or 'remove'")
|
||||
|
||||
app = get_api_app()
|
||||
is_modified = False
|
||||
existing_paths = {route.path: route for route in app.routes}
|
||||
|
||||
plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()
|
||||
for plugin_id in plugin_ids:
|
||||
routes_removed = _remove_routes(plugin_id)
|
||||
if routes_removed:
|
||||
is_modified = True
|
||||
|
||||
if action != "add":
|
||||
continue
|
||||
# 获取插件的 API 路由信息
|
||||
plugin_apis = PluginManager().get_plugin_apis(plugin_id)
|
||||
for api in plugin_apis:
|
||||
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
|
||||
try:
|
||||
api["path"] = api_path
|
||||
allow_anonymous = api.pop("allow_anonymous", False)
|
||||
auth_mode = api.pop("auth", "apikey")
|
||||
dependencies = api.setdefault("dependencies", [])
|
||||
if not allow_anonymous:
|
||||
if (
|
||||
auth_mode == "bear"
|
||||
and Depends(verify_token) not in dependencies
|
||||
):
|
||||
dependencies.append(Depends(verify_token))
|
||||
elif Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
app.add_api_route(**api, tags=["plugin"])
|
||||
is_modified = True
|
||||
logger.debug(f"Added plugin route: {api_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
|
||||
|
||||
if is_modified:
|
||||
_clean_protected_routes(existing_paths)
|
||||
app.openapi_schema = None
|
||||
app.setup()
|
||||
_route_registry().update(plugin_id, action)
|
||||
|
||||
|
||||
def _remove_routes(plugin_id: str) -> bool:
|
||||
@@ -118,37 +92,15 @@ def _remove_routes(plugin_id: str) -> bool:
|
||||
:param plugin_id: 插件 ID
|
||||
:return: 是否有路由被移除
|
||||
"""
|
||||
if not plugin_id:
|
||||
return False
|
||||
app = get_api_app()
|
||||
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
|
||||
routes_to_remove = [
|
||||
route for route in app.routes if route.path.startswith(prefix)
|
||||
]
|
||||
removed = False
|
||||
for route in routes_to_remove:
|
||||
try:
|
||||
app.routes.remove(route)
|
||||
removed = True
|
||||
logger.debug(f"Removed plugin route: {route.path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing plugin route {route.path}: {str(e)}")
|
||||
return removed
|
||||
return _route_registry().remove(plugin_id)
|
||||
|
||||
|
||||
def _clean_protected_routes(existing_paths: dict):
|
||||
def _clean_protected_routes(existing_paths: dict) -> None:
|
||||
"""
|
||||
清理受保护的路由,防止在插件操作中被删除或重复添加
|
||||
:param existing_paths: 当前应用的路由路径映射
|
||||
"""
|
||||
app = get_api_app()
|
||||
for protected_route in PROTECTED_ROUTES:
|
||||
try:
|
||||
existing_route = existing_paths.get(protected_route)
|
||||
if existing_route:
|
||||
app.routes.remove(existing_route)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
||||
_route_registry().clean(existing_paths)
|
||||
|
||||
|
||||
def remove_plugin_from_folders(plugin_id: str):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import re
|
||||
import traceback
|
||||
from typing import List, Tuple, Union, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
@@ -11,7 +11,8 @@ from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, a
|
||||
from app.adapters.system import rust as rust_accel
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas import CustomRule, FilterRuleGroup
|
||||
from app.schemas.rule import CustomRule
|
||||
from app.schemas.system import FilterRuleGroup
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""搜索应用服务。"""
|
||||
@@ -0,0 +1,135 @@
|
||||
"""搜索参数与结果缓存的应用服务。"""
|
||||
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from app.schemas.media import parse_media_key, resolve_media_identity
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def stringify_sites(sites: Optional[List[int]]) -> str:
|
||||
"""将站点 ID 列表转换为前端可复用的逗号分隔值。"""
|
||||
return ",".join(str(site) for site in sites) if sites else ""
|
||||
|
||||
|
||||
def normalize_search_params(
|
||||
params: Optional[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""把搜索缓存归一为前端重新搜索使用的稳定字段。"""
|
||||
if not isinstance(params, dict):
|
||||
return None
|
||||
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media_source=params.get("media_source"),
|
||||
media_id=params.get("media_id"),
|
||||
)
|
||||
keyword = str(params.get("keyword") or "")
|
||||
if not media_source and keyword:
|
||||
media_source, media_id = parse_media_key(keyword)
|
||||
if media_source and media_id:
|
||||
keyword = ""
|
||||
|
||||
normalized = {
|
||||
"keyword": keyword,
|
||||
"media_source": str(media_source) if media_source else "",
|
||||
"media_id": media_id or "",
|
||||
"type": str(params.get("type") or ""),
|
||||
"area": str(params.get("area") or ""),
|
||||
"title": str(params.get("title") or ""),
|
||||
"year": str(params.get("year") or ""),
|
||||
"season": str(params["season"]) if params.get("season") is not None else "",
|
||||
"episode": str(params.get("episode") or ""),
|
||||
"sites": str(params.get("sites") or ""),
|
||||
"result_type": str(params.get("result_type") or "torrent"),
|
||||
}
|
||||
if params.get("music_type"):
|
||||
normalized["music_type"] = str(params["music_type"])
|
||||
return normalized if normalized["keyword"] or media_id else None
|
||||
|
||||
|
||||
class SearchStateService:
|
||||
"""通过注入的缓存端口保存和读取搜索状态。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
save_cache: Callable[[Any, str], None],
|
||||
load_cache: Callable[[str], Any],
|
||||
async_save_cache: Callable[[Any, str], Awaitable[None]],
|
||||
async_load_cache: Callable[[str], Awaitable[Any]],
|
||||
params_key: str,
|
||||
result_key: str,
|
||||
subtitle_result_key: str,
|
||||
) -> None:
|
||||
"""保存缓存端口和兼容缓存键。"""
|
||||
self._save_cache = save_cache
|
||||
self._load_cache = load_cache
|
||||
self._async_save_cache = async_save_cache
|
||||
self._async_load_cache = async_load_cache
|
||||
self._params_key = params_key
|
||||
self._result_key = result_key
|
||||
self._subtitle_result_key = subtitle_result_key
|
||||
|
||||
@staticmethod
|
||||
def build_params(
|
||||
*,
|
||||
keyword: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
area: Optional[str] = "title",
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
sites: Optional[List[int]] = None,
|
||||
music_type: Optional[str] = None,
|
||||
result_type: Optional[str] = "torrent",
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""把公开搜索参数构造成可持久化的兼容字典。"""
|
||||
return normalize_search_params(
|
||||
{
|
||||
"keyword": keyword,
|
||||
"media_source": media_source,
|
||||
"media_id": media_id,
|
||||
"type": mtype.value if isinstance(mtype, MediaType) else mtype,
|
||||
"area": area,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"sites": stringify_sites(sites),
|
||||
"music_type": music_type,
|
||||
"result_type": result_type or "torrent",
|
||||
}
|
||||
)
|
||||
|
||||
def save_params(self, **kwargs: Any) -> None:
|
||||
"""同步保存最后一次有效搜索参数。"""
|
||||
params = self.build_params(**kwargs)
|
||||
if params:
|
||||
self._save_cache(params, self._params_key)
|
||||
|
||||
async def async_save_params(self, **kwargs: Any) -> None:
|
||||
"""异步保存最后一次有效搜索参数。"""
|
||||
params = self.build_params(**kwargs)
|
||||
if params:
|
||||
await self._async_save_cache(params, self._params_key)
|
||||
|
||||
def load_params(self) -> Optional[Dict[str, str]]:
|
||||
"""同步读取并归一化最后一次搜索参数。"""
|
||||
return normalize_search_params(self._load_cache(self._params_key))
|
||||
|
||||
async def async_load_params(self) -> Optional[Dict[str, str]]:
|
||||
"""异步读取并归一化最后一次搜索参数。"""
|
||||
return normalize_search_params(await self._async_load_cache(self._params_key))
|
||||
|
||||
def load_results(self) -> Any:
|
||||
"""同步读取最后一次资源搜索结果。"""
|
||||
return self._load_cache(self._result_key)
|
||||
|
||||
async def async_load_results(self) -> Any:
|
||||
"""异步读取最后一次资源搜索结果。"""
|
||||
return await self._async_load_cache(self._result_key)
|
||||
|
||||
async def async_load_subtitle_results(self) -> Any:
|
||||
"""异步读取最后一次字幕搜索结果。"""
|
||||
return await self._async_load_cache(self._subtitle_result_key)
|
||||
@@ -15,7 +15,7 @@ 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 import schemas
|
||||
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
|
||||
@@ -23,7 +23,7 @@ from app.runtime.log import logger
|
||||
BCRYPT_PASSWORD_MAX_BYTES = 72
|
||||
BCRYPT_ROUNDS = 12
|
||||
ALGORITHM = "HS256"
|
||||
SuperuserTokenPayloadProvider = Callable[[], schemas.TokenPayload]
|
||||
SuperuserTokenPayloadProvider = Callable[[], _SchemaTokenPayload]
|
||||
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ def __get_api_key(
|
||||
|
||||
|
||||
@cached(maxsize=1, ttl=600)
|
||||
def __create_superuser_token_payload() -> schemas.TokenPayload:
|
||||
def __create_superuser_token_payload() -> _SchemaTokenPayload:
|
||||
"""
|
||||
创建管理员用户的TokenPayload
|
||||
|
||||
@@ -164,7 +164,7 @@ def create_access_token(
|
||||
|
||||
|
||||
def set_or_refresh_resource_token_cookie(
|
||||
request: Request, response: Response, payload: schemas.TokenPayload
|
||||
request: Request, response: Response, payload: _SchemaTokenPayload
|
||||
) -> None:
|
||||
"""
|
||||
设置资源令牌 Cookie
|
||||
@@ -229,7 +229,7 @@ def set_or_refresh_resource_token_cookie(
|
||||
)
|
||||
|
||||
|
||||
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> schemas.TokenPayload:
|
||||
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> _SchemaTokenPayload:
|
||||
"""
|
||||
使用 JWT Token 进行身份认证并解析 Token 的内容
|
||||
:param token: JWT 令牌
|
||||
@@ -253,12 +253,12 @@ def __verify_token(token: str, purpose: Optional[str] = "authentication") -> sch
|
||||
token, secret_key, algorithms=[ALGORITHM]
|
||||
)
|
||||
|
||||
token_payload = schemas.TokenPayload(**payload)
|
||||
token_payload = _SchemaTokenPayload(**payload)
|
||||
|
||||
if token_payload.purpose != purpose:
|
||||
raise jwt.InvalidTokenError("令牌用途不匹配")
|
||||
|
||||
return schemas.TokenPayload(**payload)
|
||||
return _SchemaTokenPayload(**payload)
|
||||
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -272,7 +272,7 @@ def verify_token(
|
||||
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)],
|
||||
) -> schemas.TokenPayload:
|
||||
) -> _SchemaTokenPayload:
|
||||
"""
|
||||
验证 JWT 令牌并自动处理 resource_token 写入
|
||||
|
||||
@@ -310,7 +310,7 @@ def verify_token(
|
||||
|
||||
def verify_resource_token(
|
||||
resource_token: Annotated[str, Security(resource_token_cookie)]
|
||||
) -> schemas.TokenPayload:
|
||||
) -> _SchemaTokenPayload:
|
||||
"""
|
||||
验证资源访问令牌(从 Cookie 中获取)
|
||||
:param resource_token: 从 Cookie 中获取的资源访问令牌
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import Any, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app import schemas
|
||||
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.runtime.config import settings
|
||||
from app.db.models.user import User
|
||||
@@ -118,7 +119,7 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]:
|
||||
return AuthTicketStore().consume(ticket)
|
||||
|
||||
|
||||
def build_superuser_token_payload() -> schemas.TokenPayload:
|
||||
def build_superuser_token_payload() -> _SchemaTokenPayload:
|
||||
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
|
||||
user = UserOper().get_by_name(settings.SUPERUSER)
|
||||
if not user or not user.is_superuser:
|
||||
@@ -126,7 +127,7 @@ def build_superuser_token_payload() -> schemas.TokenPayload:
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户权限不足",
|
||||
)
|
||||
return schemas.TokenPayload(
|
||||
return _SchemaTokenPayload(
|
||||
sub=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
@@ -135,7 +136,7 @@ def build_superuser_token_payload() -> schemas.TokenPayload:
|
||||
)
|
||||
|
||||
|
||||
def build_token_response(user: User) -> schemas.Token:
|
||||
def build_token_response(user: User) -> _SchemaToken:
|
||||
"""
|
||||
使用系统统一逻辑构造登录 Token 响应。
|
||||
|
||||
@@ -147,7 +148,7 @@ def build_token_response(user: User) -> schemas.Token:
|
||||
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
|
||||
and not settings.ADVANCED_MODE
|
||||
)
|
||||
return schemas.Token(
|
||||
return _SchemaToken(
|
||||
access_token=security.create_access_token(
|
||||
userid=user.id,
|
||||
username=user.name,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""MoviePilot 中心服务应用用例。"""
|
||||
@@ -0,0 +1,134 @@
|
||||
"""中心服务存量上报用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.media import resolve_media_identity
|
||||
|
||||
|
||||
class ServerReportService:
|
||||
"""协调本地订阅、插件清单和中心服务统计上报。"""
|
||||
|
||||
SUBSCRIBE_FIELDS = frozenset({
|
||||
"name", "year", "type", "media_source", "media_id", "music_type",
|
||||
"total_tracks", "genre_ids", "season", "poster", "backdrop", "vote",
|
||||
"description",
|
||||
})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_reader: Callable[[Any], Any],
|
||||
config_writer: Callable[[Any, Any], Any],
|
||||
installed_plugins_provider: Callable[[], list[str]],
|
||||
subscribes_provider: Callable[[], list[Any]],
|
||||
plugin_report_sender: Callable[[list[dict]], Any],
|
||||
async_plugin_report_sender: Callable[[list[dict]], Awaitable[Any]],
|
||||
subscribe_report_sender: Callable[[list[dict]], Any],
|
||||
repo_url_sanitizer: Callable[[Optional[str]], Optional[str]],
|
||||
) -> None:
|
||||
"""保存本地读取端口和只负责 I/O 的中心服务发送端口。"""
|
||||
self._config_reader = config_reader
|
||||
self._config_writer = config_writer
|
||||
self._installed_plugins_provider = installed_plugins_provider
|
||||
self._subscribes_provider = subscribes_provider
|
||||
self._plugin_report_sender = plugin_report_sender
|
||||
self._async_plugin_report_sender = async_plugin_report_sender
|
||||
self._subscribe_report_sender = subscribe_report_sender
|
||||
self._repo_url_sanitizer = repo_url_sanitizer
|
||||
|
||||
def init_report(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
state_key: Any,
|
||||
reporter: Callable[[], bool],
|
||||
) -> None:
|
||||
"""首次成功上报后写入对应的完成标记。"""
|
||||
if enabled and not self._config_reader(state_key) and reporter():
|
||||
self._config_writer(state_key, "1")
|
||||
|
||||
def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]:
|
||||
"""构造中心服务订阅统计载荷并移除本地运行字段。"""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
media_source, media_id = resolve_media_identity(media=item)
|
||||
if not media_source or not media_id:
|
||||
return None
|
||||
payload = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in self.SUBSCRIBE_FIELDS
|
||||
}
|
||||
payload["media_source"] = str(media_source)
|
||||
payload["media_id"] = media_id
|
||||
return payload
|
||||
|
||||
def build_plugin_payload(
|
||||
self,
|
||||
items: Optional[list[tuple[str, Optional[str]]]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""构造插件安装统计载荷并脱敏本地仓库路径。"""
|
||||
if items:
|
||||
return [
|
||||
{
|
||||
"plugin_id": plugin_id,
|
||||
"repo_url": self._repo_url_sanitizer(repo_url),
|
||||
}
|
||||
for plugin_id, repo_url in items
|
||||
if plugin_id
|
||||
]
|
||||
return [
|
||||
{"plugin_id": plugin_id, "repo_url": None}
|
||||
for plugin_id in self._installed_plugins_provider()
|
||||
if plugin_id
|
||||
]
|
||||
|
||||
def report_subscribes(self, *, enabled: bool) -> bool:
|
||||
"""上报当前全部有效订阅的公开统计字段。"""
|
||||
if not enabled:
|
||||
return False
|
||||
subscribes = self._subscribes_provider()
|
||||
if not subscribes:
|
||||
return True
|
||||
payloads = [
|
||||
payload
|
||||
for subscribe in subscribes
|
||||
if (payload := self.build_subscribe_payload(subscribe.to_dict()))
|
||||
]
|
||||
if not payloads:
|
||||
return True
|
||||
response = self._subscribe_report_sender(payloads)
|
||||
return bool(response is not None and response.status_code == 200)
|
||||
|
||||
def report_plugins(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
items: Optional[list[tuple[str, Optional[str]]]] = None,
|
||||
) -> bool:
|
||||
"""同步上报当前插件安装清单。"""
|
||||
if not enabled:
|
||||
return False
|
||||
payload = self.build_plugin_payload(items)
|
||||
if not payload:
|
||||
return False
|
||||
response = self._plugin_report_sender(payload)
|
||||
return bool(response is not None and response.status_code == 200)
|
||||
|
||||
async def async_report_plugins(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
items: Optional[list[tuple[str, Optional[str]]]] = None,
|
||||
) -> bool:
|
||||
"""异步上报当前插件安装清单。"""
|
||||
if not enabled:
|
||||
return False
|
||||
payload = self.build_plugin_payload(items)
|
||||
if not payload:
|
||||
return False
|
||||
response = await self._async_plugin_report_sender(payload)
|
||||
return bool(response is not None and response.status_code == 200)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""中心服务订阅和工作流分享用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.schemas.media import resolve_media_identity
|
||||
|
||||
|
||||
class ServerSharingService:
|
||||
"""协调本地订阅、工作流读取与中心服务分享传输。"""
|
||||
|
||||
SUBSCRIBE_FIELDS = frozenset({
|
||||
"share_title", "share_comment", "share_user", "share_uid", "name",
|
||||
"year", "type", "keyword", "media_source", "media_id", "music_type",
|
||||
"total_tracks", "season", "poster", "backdrop", "vote", "description",
|
||||
"genre_ids", "include", "exclude", "quality", "resolution", "effect",
|
||||
"total_episode", "custom_words", "media_category", "episode_group",
|
||||
"date",
|
||||
})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
subscribe_provider: Callable[[int], Any],
|
||||
async_subscribe_provider: Callable[[int], Awaitable[Any]],
|
||||
workflow_provider: Callable[[int], Any],
|
||||
async_workflow_provider: Callable[[int], Awaitable[Any]],
|
||||
user_uuid_provider: Callable[[], str],
|
||||
subscribe_sender: Callable[[dict], Any],
|
||||
async_subscribe_sender: Callable[[dict], Awaitable[Any]],
|
||||
workflow_sender: Callable[[dict], Any],
|
||||
async_workflow_sender: Callable[[dict], Awaitable[Any]],
|
||||
response_handler: Callable[[Any, Callable[[], None]], tuple[bool, str]],
|
||||
subscribe_cache_clearer: Callable[[], None],
|
||||
workflow_cache_clearer: Callable[[], None],
|
||||
) -> None:
|
||||
"""保存本地数据端口、中心服务传输端口和缓存失效端口。"""
|
||||
self._subscribe_provider = subscribe_provider
|
||||
self._async_subscribe_provider = async_subscribe_provider
|
||||
self._workflow_provider = workflow_provider
|
||||
self._async_workflow_provider = async_workflow_provider
|
||||
self._user_uuid_provider = user_uuid_provider
|
||||
self._subscribe_sender = subscribe_sender
|
||||
self._async_subscribe_sender = async_subscribe_sender
|
||||
self._workflow_sender = workflow_sender
|
||||
self._async_workflow_sender = async_workflow_sender
|
||||
self._response_handler = response_handler
|
||||
self._subscribe_cache_clearer = subscribe_cache_clearer
|
||||
self._workflow_cache_clearer = workflow_cache_clearer
|
||||
|
||||
def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]:
|
||||
"""构造订阅分享载荷并隔离本地字段和旧专用 ID。"""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
media_source, media_id = resolve_media_identity(media=item)
|
||||
if not media_source or not media_id:
|
||||
return None
|
||||
payload = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in self.SUBSCRIBE_FIELDS
|
||||
}
|
||||
payload["media_source"] = str(media_source)
|
||||
payload["media_id"] = media_id
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def prepare_workflow(workflow: Any) -> dict:
|
||||
"""移除本地字段并把动作和流程编码为中心服务兼容格式。"""
|
||||
workflow_dict = workflow.to_dict()
|
||||
workflow_dict.pop("id", None)
|
||||
workflow_dict.pop("context", None)
|
||||
workflow_dict["actions"] = json.dumps(workflow_dict["actions"] or [])
|
||||
workflow_dict["flows"] = json.dumps(workflow_dict["flows"] or [])
|
||||
return workflow_dict
|
||||
|
||||
@staticmethod
|
||||
def validate_workflow(workflow: Any) -> tuple[bool, str]:
|
||||
"""验证工作流存在且同时包含动作与流程。"""
|
||||
if not workflow:
|
||||
return False, "工作流不存在"
|
||||
if not workflow.actions or not workflow.flows:
|
||||
return False, "请分享有动作和流程的工作流"
|
||||
return True, ""
|
||||
|
||||
def share_subscribe(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
subscribe_id: int,
|
||||
share_title: str,
|
||||
share_comment: str,
|
||||
share_user: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""同步读取并分享指定订阅。"""
|
||||
if not enabled:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
subscribe = self._subscribe_provider(subscribe_id)
|
||||
if not subscribe:
|
||||
return False, "订阅不存在"
|
||||
payload = self.build_subscribe_payload({
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._user_uuid_provider(),
|
||||
**subscribe.to_dict(),
|
||||
})
|
||||
if not payload:
|
||||
return False, "订阅媒体身份不完整"
|
||||
return self._response_handler(
|
||||
self._subscribe_sender(payload),
|
||||
self._subscribe_cache_clearer,
|
||||
)
|
||||
|
||||
async def async_share_subscribe(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
subscribe_id: int,
|
||||
share_title: str,
|
||||
share_comment: str,
|
||||
share_user: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""异步读取并分享指定订阅。"""
|
||||
if not enabled:
|
||||
return False, "当前没有开启订阅数据共享功能"
|
||||
subscribe = await self._async_subscribe_provider(subscribe_id)
|
||||
if not subscribe:
|
||||
return False, "订阅不存在"
|
||||
payload = self.build_subscribe_payload({
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._user_uuid_provider(),
|
||||
**subscribe.to_dict(),
|
||||
})
|
||||
if not payload:
|
||||
return False, "订阅媒体身份不完整"
|
||||
return self._response_handler(
|
||||
await self._async_subscribe_sender(payload),
|
||||
self._subscribe_cache_clearer,
|
||||
)
|
||||
|
||||
def share_workflow(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
workflow_id: int,
|
||||
share_title: str,
|
||||
share_comment: str,
|
||||
share_user: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""同步读取并分享指定工作流。"""
|
||||
if not enabled:
|
||||
return False, "当前没有开启工作流数据共享功能"
|
||||
workflow = self._workflow_provider(workflow_id)
|
||||
valid, message = self.validate_workflow(workflow)
|
||||
if not valid:
|
||||
return False, message
|
||||
payload = {
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._user_uuid_provider(),
|
||||
**self.prepare_workflow(workflow),
|
||||
}
|
||||
return self._response_handler(
|
||||
self._workflow_sender(payload),
|
||||
self._workflow_cache_clearer,
|
||||
)
|
||||
|
||||
async def async_share_workflow(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
workflow_id: int,
|
||||
share_title: str,
|
||||
share_comment: str,
|
||||
share_user: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""异步读取并分享指定工作流。"""
|
||||
if not enabled:
|
||||
return False, "当前没有开启工作流数据共享功能"
|
||||
workflow = await self._async_workflow_provider(workflow_id)
|
||||
valid, message = self.validate_workflow(workflow)
|
||||
if not valid:
|
||||
return False, message
|
||||
payload = {
|
||||
"share_title": share_title,
|
||||
"share_comment": share_comment,
|
||||
"share_user": share_user,
|
||||
"share_uid": self._user_uuid_provider(),
|
||||
**self.prepare_workflow(workflow),
|
||||
}
|
||||
return self._response_handler(
|
||||
await self._async_workflow_sender(payload),
|
||||
self._workflow_cache_clearer,
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""站点写操作应用用例。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol
|
||||
|
||||
from app.application.subscription.delete import AsyncUnitOfWork
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SiteMutationResult:
|
||||
"""描述站点写操作是否成功及兼容提示信息。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class SiteMutationRepository(Protocol):
|
||||
"""站点写用例需要的最小持久化端口。"""
|
||||
|
||||
async def get_by_id(self, site_id: int) -> Optional[Any]:
|
||||
"""读取指定站点。"""
|
||||
...
|
||||
|
||||
async def get_by_domain(self, domain: str) -> Optional[Any]:
|
||||
"""按域名读取站点。"""
|
||||
...
|
||||
|
||||
async def stage_create(self, payload: Mapping[str, Any]) -> None:
|
||||
"""暂存新增站点。"""
|
||||
...
|
||||
|
||||
async def stage_update(self, site_id: int, payload: Mapping[str, Any]) -> bool:
|
||||
"""暂存站点更新并返回目标是否存在。"""
|
||||
...
|
||||
|
||||
async def stage_delete(self, site_id: int) -> None:
|
||||
"""暂存站点删除。"""
|
||||
...
|
||||
|
||||
async def stage_priorities(self, priorities: list[dict]) -> None:
|
||||
"""暂存一组站点优先级变更。"""
|
||||
...
|
||||
|
||||
|
||||
SiteIndexerLoader = Callable[[str], Awaitable[Optional[dict]]]
|
||||
SiteEventPublisher = Callable[[dict], Awaitable[None]]
|
||||
DomainExtractor = Callable[[str], str]
|
||||
UrlNormalizer = Callable[[str], str]
|
||||
|
||||
|
||||
class SiteMutationCommand:
|
||||
"""统一执行站点新增、更新、优先级和删除事务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: SiteMutationRepository,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
auth_level_provider: Callable[[], int],
|
||||
indexer_loader: SiteIndexerLoader,
|
||||
domain_extractor: DomainExtractor,
|
||||
url_normalizer: UrlNormalizer,
|
||||
publish_updated: SiteEventPublisher,
|
||||
publish_deleted: SiteEventPublisher,
|
||||
) -> None:
|
||||
"""保存站点验证、持久化、事务和提交后事件端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._auth_level_provider = auth_level_provider
|
||||
self._indexer_loader = indexer_loader
|
||||
self._domain_extractor = domain_extractor
|
||||
self._url_normalizer = url_normalizer
|
||||
self._publish_updated = publish_updated
|
||||
self._publish_deleted = publish_deleted
|
||||
|
||||
async def create(self, payload: Mapping[str, Any]) -> SiteMutationResult:
|
||||
"""校验并新增站点,提交成功后发布站点更新事件。"""
|
||||
values = dict(payload)
|
||||
raw_url = values.get("url")
|
||||
if not raw_url:
|
||||
return SiteMutationResult(False, "站点地址不能为空")
|
||||
if self._auth_level_provider() < 2:
|
||||
return SiteMutationResult(False, "用户未通过认证,无法使用站点功能!")
|
||||
|
||||
domain = self._domain_extractor(raw_url)
|
||||
site_info = await self._indexer_loader(domain)
|
||||
if not site_info:
|
||||
return SiteMutationResult(False, "该站点不支持,请检查站点域名是否正确")
|
||||
if await self._repository.get_by_domain(domain):
|
||||
return SiteMutationResult(False, f"{domain} 站点己存在")
|
||||
|
||||
values.update({
|
||||
"id": None,
|
||||
"domain": domain,
|
||||
"url": self._url_normalizer(raw_url),
|
||||
"name": site_info.get("name"),
|
||||
"public": 1 if site_info.get("public") else 0,
|
||||
})
|
||||
await self._repository.stage_create(values)
|
||||
await self._commit()
|
||||
await self._publish_updated({"domain": domain})
|
||||
return SiteMutationResult(True)
|
||||
|
||||
async def update(self, payload: Mapping[str, Any]) -> SiteMutationResult:
|
||||
"""更新站点并在提交后发布完整的兼容事件载荷。"""
|
||||
values = dict(payload)
|
||||
site_id = values.get("id")
|
||||
if not site_id or not await self._repository.get_by_id(site_id):
|
||||
return SiteMutationResult(False, "站点不存在")
|
||||
|
||||
values["url"] = self._url_normalizer(values.get("url") or "")
|
||||
values["domain"] = self._domain_extractor(values["url"])
|
||||
await self._repository.stage_update(site_id, values)
|
||||
await self._commit()
|
||||
await self._publish_updated({
|
||||
"site_id": site_id,
|
||||
"domain": values["domain"],
|
||||
"name": values.get("name"),
|
||||
"site_url": values["url"],
|
||||
})
|
||||
return SiteMutationResult(True)
|
||||
|
||||
async def update_priorities(self, priorities: list[dict]) -> SiteMutationResult:
|
||||
"""在同一事务中更新全部站点优先级。"""
|
||||
await self._repository.stage_priorities(priorities)
|
||||
await self._commit()
|
||||
return SiteMutationResult(True)
|
||||
|
||||
async def delete(self, site_id: int) -> SiteMutationResult:
|
||||
"""删除站点,并确保删除事件只在提交成功后发送。"""
|
||||
await self._repository.stage_delete(site_id)
|
||||
await self._commit()
|
||||
await self._publish_deleted({"site_id": site_id})
|
||||
return SiteMutationResult(True)
|
||||
|
||||
async def _commit(self) -> None:
|
||||
"""提交当前站点事务,失败时回滚并保留原始异常。"""
|
||||
try:
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.system import StorageConf as _SchemaStorageConf
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
@@ -11,16 +11,16 @@ class StorageHelper:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_storagies() -> List[schemas.StorageConf]:
|
||||
def get_storagies() -> List[_SchemaStorageConf]:
|
||||
"""
|
||||
获取所有存储设置
|
||||
"""
|
||||
storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages)
|
||||
if not storage_confs:
|
||||
return []
|
||||
return [schemas.StorageConf(**s) for s in storage_confs]
|
||||
return [_SchemaStorageConf(**s) for s in storage_confs]
|
||||
|
||||
def get_storage(self, storage: str) -> Optional[schemas.StorageConf]:
|
||||
def get_storage(self, storage: str) -> Optional[_SchemaStorageConf]:
|
||||
"""
|
||||
获取指定存储配置
|
||||
"""
|
||||
@@ -37,7 +37,7 @@ class StorageHelper:
|
||||
storagies = self.get_storagies()
|
||||
if not storagies:
|
||||
storagies = [
|
||||
schemas.StorageConf(
|
||||
_SchemaStorageConf(
|
||||
type=storage,
|
||||
config=conf
|
||||
)
|
||||
@@ -56,14 +56,14 @@ class StorageHelper:
|
||||
storagies = self.get_storagies()
|
||||
if not storagies:
|
||||
storagies = [
|
||||
schemas.StorageConf(
|
||||
_SchemaStorageConf(
|
||||
type=storage,
|
||||
name=name,
|
||||
config=conf
|
||||
)
|
||||
]
|
||||
else:
|
||||
storagies.append(schemas.StorageConf(
|
||||
storagies.append(_SchemaStorageConf(
|
||||
type=storage,
|
||||
name=name,
|
||||
config=conf
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""订阅应用契约与写用例。"""
|
||||
@@ -0,0 +1,67 @@
|
||||
"""订阅编排共享的媒体元数据与身份契约。"""
|
||||
|
||||
from typing import Optional, Protocol, Union
|
||||
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.media import build_media_key, resolve_media_identity
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
|
||||
|
||||
|
||||
class SubscribeSnapshot(Protocol):
|
||||
"""构造订阅媒体契约所需的最小只读字段集合。"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
year: Optional[str]
|
||||
season: Optional[int]
|
||||
media_source: object
|
||||
media_id: object
|
||||
music_type: Optional[str]
|
||||
total_tracks: Optional[int]
|
||||
|
||||
|
||||
def build_subscribe_meta(subscribe: SubscribeSnapshot) -> MetaBase:
|
||||
"""按订阅快照构造主程序链路共用的媒体元数据。"""
|
||||
if subscribe.type == MediaType.MUSIC.value:
|
||||
is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM
|
||||
return MetaMusic(
|
||||
title=subscribe.name,
|
||||
album=subscribe.name if is_album else None,
|
||||
year=subscribe.year,
|
||||
total_tracks=(
|
||||
getattr(subscribe, "total_tracks", None) if is_album else None
|
||||
),
|
||||
media_source=subscribe.media_source,
|
||||
media_id=(
|
||||
str(subscribe.media_id)
|
||||
if subscribe.media_id is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season
|
||||
meta.type = MediaType(subscribe.type)
|
||||
meta.media_source = subscribe.media_source
|
||||
meta.media_id = subscribe.media_id
|
||||
return meta
|
||||
|
||||
|
||||
def subscribe_media_key(
|
||||
subscribe: SubscribeSnapshot,
|
||||
) -> Union[str, int, None]:
|
||||
"""返回订阅缺失集映射使用的稳定媒体键。"""
|
||||
media_source, media_id = resolve_media_identity(media=subscribe)
|
||||
return build_media_key(media_source, media_id) or media_id
|
||||
|
||||
|
||||
def subscribe_media_keys(subscribe: SubscribeSnapshot) -> list[Union[str, int]]:
|
||||
"""返回缺失集缓存可识别的规范媒体键与旧纯 ID 键。"""
|
||||
media_source, media_id = resolve_media_identity(media=subscribe)
|
||||
candidates = [
|
||||
build_media_key(media_source, media_id),
|
||||
media_id,
|
||||
]
|
||||
return [candidate for candidate in candidates if candidate not in (None, "")]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""订阅删除应用用例及其依赖端口。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Mapping, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscribeDeletionActor:
|
||||
"""执行订阅删除的用户身份。"""
|
||||
|
||||
username: str
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscribeDeletionCandidate:
|
||||
"""删除前读取出的订阅快照,不向应用层暴露 ORM 对象。"""
|
||||
|
||||
subscribe_id: int
|
||||
username: str | None
|
||||
event_payload: Mapping[str, object]
|
||||
|
||||
|
||||
class SubscribeDeletionRepository(Protocol):
|
||||
"""订阅删除用例需要的最小数据访问端口。"""
|
||||
|
||||
async def get_candidate(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> SubscribeDeletionCandidate | None:
|
||||
"""读取订阅及删除事件所需的稳定快照。"""
|
||||
...
|
||||
|
||||
async def stage_delete(self, subscribe_id: int) -> None:
|
||||
"""把已读取的订阅登记为待删除,但不自行提交事务。"""
|
||||
...
|
||||
|
||||
|
||||
class AsyncUnitOfWork(Protocol):
|
||||
"""订阅写用例使用的异步事务端口。"""
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
SubscribeDeletedPublisher = Callable[
|
||||
[int, Mapping[str, object]],
|
||||
Awaitable[None],
|
||||
]
|
||||
SubscribeDeletedReporter = Callable[[Mapping[str, object]], object]
|
||||
|
||||
|
||||
class DeleteSubscribeCommand:
|
||||
"""按权限删除订阅,并在提交成功后依次发送事件和统计上报。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscribeDeletionRepository,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
report_deleted: SubscribeDeletedReporter,
|
||||
) -> None:
|
||||
"""注入数据访问、事务与提交后副作用端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._publish_deleted = publish_deleted
|
||||
self._report_deleted = report_deleted
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""
|
||||
删除当前用户可访问的订阅。
|
||||
|
||||
返回 False 表示订阅不存在或无权访问;该结果由 API 映射为历史兼容的成功响应。
|
||||
提交后的事件与上报保持原有顺序,任一副作用失败都会继续向调用方抛出。
|
||||
"""
|
||||
candidate = await self._repository.get_candidate(subscribe_id)
|
||||
if not self._can_delete(candidate, actor):
|
||||
return False
|
||||
|
||||
await self._repository.stage_delete(subscribe_id)
|
||||
try:
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
event_payload = dict(candidate.event_payload)
|
||||
await self._publish_deleted(subscribe_id, event_payload)
|
||||
self._report_deleted(
|
||||
{
|
||||
"media_source": event_payload.get("media_source"),
|
||||
"media_id": event_payload.get("media_id"),
|
||||
"season": event_payload.get("season"),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _can_delete(
|
||||
candidate: SubscribeDeletionCandidate | None,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""判断用户是否拥有目标订阅的删除权限。"""
|
||||
if candidate is None:
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
@@ -0,0 +1,98 @@
|
||||
"""按媒体身份批量删除订阅的应用用例。"""
|
||||
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.application.subscription.delete import (
|
||||
AsyncUnitOfWork,
|
||||
SubscribeDeletedPublisher,
|
||||
SubscribeDeletionActor,
|
||||
SubscribeDeletionCandidate,
|
||||
)
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class SubscribeIdentityDeletionRepository(Protocol):
|
||||
"""按媒体身份删除订阅所需的数据访问端口。"""
|
||||
|
||||
async def list_candidates_by_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: int | None,
|
||||
music_type: str | None,
|
||||
) -> list[SubscribeDeletionCandidate]:
|
||||
"""读取匹配媒体身份的去重订阅快照。"""
|
||||
...
|
||||
|
||||
async def delete(self, subscribe_id: int) -> None:
|
||||
"""把指定订阅登记为待删除。"""
|
||||
...
|
||||
|
||||
|
||||
SubscribeDeletionEventErrorHandler = Callable[[int, Exception], None]
|
||||
|
||||
|
||||
class DeleteSubscriptionsByIdentityCommand:
|
||||
"""按媒体身份删除当前用户可访问的全部订阅。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscribeIdentityDeletionRepository,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
publish_deleted: SubscribeDeletedPublisher,
|
||||
handle_event_error: SubscribeDeletionEventErrorHandler,
|
||||
) -> None:
|
||||
"""注入数据访问、事务、事件和事件错误处理端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._publish_deleted = publish_deleted
|
||||
self._handle_event_error = handle_event_error
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: int | None,
|
||||
music_type: str | None,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> int:
|
||||
"""删除匹配订阅,并在提交后逐条发送兼容事件。"""
|
||||
candidates = await self._repository.list_candidates_by_identity(
|
||||
media_source,
|
||||
media_id,
|
||||
season,
|
||||
music_type,
|
||||
)
|
||||
deletions = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if self._can_delete(candidate, actor)
|
||||
]
|
||||
for candidate in deletions:
|
||||
await self._repository.stage_delete(candidate.subscribe_id)
|
||||
|
||||
try:
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
for candidate in deletions:
|
||||
try:
|
||||
await self._publish_deleted(
|
||||
candidate.subscribe_id,
|
||||
dict(candidate.event_payload),
|
||||
)
|
||||
except Exception as error:
|
||||
self._handle_event_error(candidate.subscribe_id, error)
|
||||
return len(deletions)
|
||||
|
||||
@staticmethod
|
||||
def _can_delete(
|
||||
candidate: SubscribeDeletionCandidate,
|
||||
actor: SubscribeDeletionActor,
|
||||
) -> bool:
|
||||
"""判断用户是否拥有候选订阅的删除权限。"""
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
@@ -0,0 +1,76 @@
|
||||
"""订阅存在性、来源定位和类型状态查询应用服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SubscriptionQueryRepository(Protocol):
|
||||
"""描述订阅查询切片所需的最小仓储能力。"""
|
||||
|
||||
def exists(self, **identity: Any) -> bool:
|
||||
"""按完整订阅身份判断记录是否存在。"""
|
||||
...
|
||||
|
||||
def get_by(self, **identity: Any) -> Optional[Any]:
|
||||
"""按来源关键字中的订阅身份读取单条记录。"""
|
||||
...
|
||||
|
||||
def list(self, state: Optional[str] = None) -> list[Any]:
|
||||
"""按可选状态集合读取订阅记录。"""
|
||||
...
|
||||
|
||||
|
||||
class SubscriptionQueryService:
|
||||
"""封装不修改订阅状态的三个公开查询用例。"""
|
||||
|
||||
_SOURCE_FIELDS = {
|
||||
"type",
|
||||
"season",
|
||||
"media_source",
|
||||
"media_id",
|
||||
"music_type",
|
||||
}
|
||||
|
||||
def __init__(self, repository: SubscriptionQueryRepository) -> None:
|
||||
"""保存订阅查询仓储端口。"""
|
||||
self._repository = repository
|
||||
|
||||
def exists(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
meta: Optional[MetaBase] = None,
|
||||
) -> bool:
|
||||
"""按媒体身份、季、剧集组和音乐实体类型判断订阅是否存在。"""
|
||||
media_source, media_id = resolve_media_identity(media=mediainfo)
|
||||
return bool(self._repository.exists(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=getattr(mediainfo, "music_type", None)
|
||||
if mediainfo.type == MediaType.MUSIC else None,
|
||||
season=meta.begin_season if meta else None,
|
||||
episode_group=mediainfo.episode_group,
|
||||
))
|
||||
|
||||
def get_by_source(self, source_keyword: Optional[dict]) -> Optional[Any]:
|
||||
"""从已解析来源关键字筛出稳定身份字段并读取订阅。"""
|
||||
if not source_keyword:
|
||||
return None
|
||||
identity = {
|
||||
key: value
|
||||
for key, value in source_keyword.items()
|
||||
if key in self._SOURCE_FIELDS
|
||||
}
|
||||
return self._repository.get_by(**identity)
|
||||
|
||||
def has_music(self, searchable_states: str) -> bool:
|
||||
"""判断给定可搜索状态内是否至少存在一个音乐订阅。"""
|
||||
return any(
|
||||
subscribe.type == MediaType.MUSIC.value
|
||||
for subscribe in self._repository.list(searchable_states) or []
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""手工订阅搜索应用用例。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.application.subscription.delete import SubscribeDeletionCandidate
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubscribeSearchActor:
|
||||
"""执行手工订阅搜索的用户身份。"""
|
||||
|
||||
username: str
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
class SubscribeSearchRepository(Protocol):
|
||||
"""手工订阅搜索所需的最小读取端口。"""
|
||||
|
||||
async def get_candidate(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
) -> SubscribeDeletionCandidate | None:
|
||||
"""读取单条订阅的归属信息。"""
|
||||
...
|
||||
|
||||
async def list_search_ids(self, username: str, state: str) -> list[int]:
|
||||
"""返回用户当前可搜索状态下的订阅编号。"""
|
||||
...
|
||||
|
||||
|
||||
SubscribeSearchScheduler = Callable[[int | None, str | None], None]
|
||||
|
||||
|
||||
class SearchSubscriptionsCommand:
|
||||
"""按用户权限生成并提交手工订阅搜索任务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscribeSearchRepository,
|
||||
schedule_search: SubscribeSearchScheduler,
|
||||
) -> None:
|
||||
"""注入订阅读取端口和后台任务提交端口。"""
|
||||
self._repository = repository
|
||||
self._schedule_search = schedule_search
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
actor: SubscribeSearchActor,
|
||||
subscribe_id: int | None = None,
|
||||
) -> bool:
|
||||
"""提交单条或当前用户全部可搜索订阅,返回目标是否存在。"""
|
||||
if subscribe_id is not None:
|
||||
candidate = await self._repository.get_candidate(subscribe_id)
|
||||
if not self._can_access(candidate, actor):
|
||||
return False
|
||||
self._schedule_search(subscribe_id, None)
|
||||
return True
|
||||
|
||||
if actor.is_superuser:
|
||||
self._schedule_search(None, "R")
|
||||
return True
|
||||
|
||||
subscribe_ids = await self._repository.list_search_ids(
|
||||
actor.username,
|
||||
"R",
|
||||
)
|
||||
for current_id in subscribe_ids:
|
||||
self._schedule_search(current_id, None)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _can_access(
|
||||
candidate: SubscribeDeletionCandidate | None,
|
||||
actor: SubscribeSearchActor,
|
||||
) -> bool:
|
||||
"""沿用订阅读取接口的超级用户和归属用户权限语义。"""
|
||||
if candidate is None:
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
return bool(candidate.username) and candidate.username == actor.username
|
||||
+56
-11
@@ -22,7 +22,10 @@ from typing import Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.transfer import MetaInfo as _SchemaMetaInfo
|
||||
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
|
||||
from app.schemas.transfer import MusicMeta as _SchemaMusicMeta
|
||||
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.application.agent import get_prompt_manager, get_running_agent_manager
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
@@ -31,7 +34,6 @@ from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.foundation import text as text_tools
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.agent import ReplyMode
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
|
||||
@@ -43,6 +45,7 @@ from app.schemas.types import (
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
ReplyMode,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,6 +112,49 @@ class TransferQueue(BaseModel):
|
||||
result: Optional[TransferInfo] = None
|
||||
|
||||
|
||||
class TransferQueueService:
|
||||
"""协调整理任务登记、入队、移除和队列视图查询。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
register_task: Callable[[TransferTask], bool],
|
||||
enqueue: Callable[[TransferQueue], None],
|
||||
before_enqueue: Callable[[TransferTask], None],
|
||||
after_enqueue: Callable[[TransferTask], None],
|
||||
remove_task: Callable[[FileItem], None],
|
||||
list_tasks: Callable[[], List[TransferJob]],
|
||||
expire_tasks: Callable[[], None],
|
||||
) -> None:
|
||||
"""保存队列用例依赖,避免 Application 服务绑定具体线程队列实现。"""
|
||||
self._register_task = register_task
|
||||
self._enqueue = enqueue
|
||||
self._before_enqueue = before_enqueue
|
||||
self._after_enqueue = after_enqueue
|
||||
self._remove_task = remove_task
|
||||
self._list_tasks = list_tasks
|
||||
self._expire_tasks = expire_tasks
|
||||
|
||||
def put(self, task: TransferTask, callback: Callable) -> bool:
|
||||
"""登记并入队一个整理任务,保持原有副作用顺序。"""
|
||||
if not task or not self._register_task(task):
|
||||
return False
|
||||
self._before_enqueue(task)
|
||||
self._enqueue(TransferQueue(task=task, callback=callback))
|
||||
self._after_enqueue(task)
|
||||
return True
|
||||
|
||||
def remove(self, fileitem: FileItem) -> None:
|
||||
"""从整理任务视图移除指定文件。"""
|
||||
if fileitem:
|
||||
self._remove_task(fileitem)
|
||||
|
||||
def list(self) -> List[TransferJob]:
|
||||
"""先处理失活任务,再返回当前整理作业视图。"""
|
||||
self._expire_tasks()
|
||||
return self._list_tasks()
|
||||
|
||||
|
||||
# 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。
|
||||
job_lock = threading.Lock()
|
||||
|
||||
@@ -214,7 +260,7 @@ class JobManager:
|
||||
return self.__get_id(task)
|
||||
|
||||
@staticmethod
|
||||
def __get_media(task: TransferTask) -> Union[schemas.MediaInfo, schemas.MusicInfo]:
|
||||
def __get_media(task: TransferTask) -> Union[_SchemaMediaInfo, _SchemaMusicInfo]:
|
||||
"""
|
||||
获取媒体信息
|
||||
"""
|
||||
@@ -223,15 +269,15 @@ class JobManager:
|
||||
mediainfo = deepcopy(task.mediainfo)
|
||||
mediainfo.clear()
|
||||
if isinstance(mediainfo, MusicInfo):
|
||||
return schemas.MusicInfo(**mediainfo.to_dict())
|
||||
return schemas.MediaInfo(**mediainfo.to_dict())
|
||||
return _SchemaMusicInfo(**mediainfo.to_dict())
|
||||
return _SchemaMediaInfo(**mediainfo.to_dict())
|
||||
else:
|
||||
# 没有媒体信息
|
||||
meta: MetaBase = task.meta
|
||||
if isinstance(meta, MetaMusic):
|
||||
# 未识别的音乐按已解析元数据兜底展示;音乐年份为 int,
|
||||
# 不能复用 MediaInfo(year 为 str),否则触发 pydantic 校验异常
|
||||
return schemas.MusicInfo(
|
||||
return _SchemaMusicInfo(
|
||||
title=meta.name,
|
||||
artists=list(meta.artists or []),
|
||||
artist=meta.artist,
|
||||
@@ -242,7 +288,7 @@ class JobManager:
|
||||
media_source=meta.media_source,
|
||||
media_id=meta.media_id,
|
||||
)
|
||||
return schemas.MediaInfo(
|
||||
return _SchemaMediaInfo(
|
||||
title=meta.name,
|
||||
year=meta.year,
|
||||
title_year=f"{meta.name} ({meta.year})",
|
||||
@@ -250,13 +296,13 @@ class JobManager:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __get_meta(task: TransferTask) -> schemas.MetaInfo:
|
||||
def __get_meta(task: TransferTask) -> _SchemaMetaInfo:
|
||||
"""
|
||||
获取元数据
|
||||
"""
|
||||
if isinstance(task.meta, MetaMusic):
|
||||
return schemas.MusicMeta(**task.meta.to_dict())
|
||||
return schemas.MetaInfo(**task.meta.to_dict())
|
||||
return _SchemaMusicMeta(**task.meta.to_dict())
|
||||
return _SchemaMetaInfo(**task.meta.to_dict())
|
||||
|
||||
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
|
||||
"""
|
||||
@@ -975,4 +1021,3 @@ class FailedRetryScheduler:
|
||||
logger.error(
|
||||
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""工作流状态与定义写操作应用用例。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from collections.abc import Awaitable
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Mapping, Optional, Protocol
|
||||
|
||||
|
||||
WORKFLOW_TRIGGER_TIMER = "timer"
|
||||
WORKFLOW_TRIGGER_EVENT = "event"
|
||||
WORKFLOW_TRIGGER_MANUAL = "manual"
|
||||
SUPPORTED_WORKFLOW_TRIGGERS = {
|
||||
WORKFLOW_TRIGGER_TIMER,
|
||||
WORKFLOW_TRIGGER_EVENT,
|
||||
WORKFLOW_TRIGGER_MANUAL,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowMutationResult:
|
||||
"""描述工作流写操作是否成功及兼容提示信息。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class WorkflowMutationRepository(Protocol):
|
||||
"""工作流写用例需要的最小持久化端口。"""
|
||||
|
||||
def get(self, workflow_id: int) -> Optional[Any]:
|
||||
"""读取工作流。"""
|
||||
...
|
||||
|
||||
def stage_state(self, workflow_id: int, state: str) -> bool:
|
||||
"""暂存工作流状态变更。"""
|
||||
...
|
||||
|
||||
def stage_update(self, workflow_id: int, payload: Mapping[str, Any]) -> Optional[Any]:
|
||||
"""暂存工作流定义更新并返回更新后的对象。"""
|
||||
...
|
||||
|
||||
def stage_delete(self, workflow_id: int) -> None:
|
||||
"""暂存工作流删除。"""
|
||||
...
|
||||
|
||||
|
||||
class UnitOfWork(Protocol):
|
||||
"""同步工作流写用例使用的事务端口。"""
|
||||
|
||||
def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
class WorkflowMutationCommand:
|
||||
"""协调工作流状态、定义、调度和事件注册变更。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: WorkflowMutationRepository,
|
||||
unit_of_work: UnitOfWork,
|
||||
add_timer: Callable[[Any], None],
|
||||
remove_timer: Callable[[Any], None],
|
||||
load_event: Callable[[int], None],
|
||||
remove_event: Callable[[int, Optional[str]], None],
|
||||
refresh_event: Callable[[Any], None],
|
||||
stop_running: Callable[[int], None],
|
||||
delete_cache: Callable[[int], None],
|
||||
) -> None:
|
||||
"""保存工作流事务和提交后运行时副作用端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._add_timer = add_timer
|
||||
self._remove_timer = remove_timer
|
||||
self._load_event = load_event
|
||||
self._remove_event = remove_event
|
||||
self._refresh_event = refresh_event
|
||||
self._stop_running = stop_running
|
||||
self._delete_cache = delete_cache
|
||||
|
||||
def start(self, workflow_id: int) -> WorkflowMutationResult:
|
||||
"""启用工作流,并在提交后登记定时器或事件触发器。"""
|
||||
workflow = self._repository.get(workflow_id)
|
||||
if not workflow:
|
||||
return WorkflowMutationResult(False, "工作流不存在")
|
||||
trigger_type = workflow.trigger_type or WORKFLOW_TRIGGER_TIMER
|
||||
if trigger_type == WORKFLOW_TRIGGER_TIMER and not workflow.timer:
|
||||
return WorkflowMutationResult(False, "定时工作流缺少定时器配置")
|
||||
if trigger_type not in SUPPORTED_WORKFLOW_TRIGGERS:
|
||||
return WorkflowMutationResult(False, "工作流触发类型不支持")
|
||||
|
||||
self._repository.stage_state(workflow_id, "W")
|
||||
self._commit()
|
||||
if trigger_type == WORKFLOW_TRIGGER_TIMER:
|
||||
self._add_timer(workflow)
|
||||
elif trigger_type == WORKFLOW_TRIGGER_EVENT:
|
||||
self._load_event(workflow_id)
|
||||
return WorkflowMutationResult(True)
|
||||
|
||||
def pause(self, workflow_id: int) -> WorkflowMutationResult:
|
||||
"""停用工作流,并在提交后移除运行时触发器和执行状态。"""
|
||||
workflow = self._repository.get(workflow_id)
|
||||
if not workflow:
|
||||
return WorkflowMutationResult(False, "工作流不存在")
|
||||
|
||||
self._repository.stage_state(workflow_id, "P")
|
||||
self._commit()
|
||||
if workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
|
||||
self._remove_timer(workflow)
|
||||
elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT:
|
||||
self._remove_event(workflow_id, workflow.event_type)
|
||||
self._stop_running(workflow_id)
|
||||
return WorkflowMutationResult(True)
|
||||
|
||||
def update(self, payload: Mapping[str, Any]) -> WorkflowMutationResult:
|
||||
"""更新工作流定义,并在提交后刷新调度器和事件注册。"""
|
||||
values = dict(payload)
|
||||
workflow_id = values.get("id")
|
||||
if not workflow_id:
|
||||
return WorkflowMutationResult(False, "工作流ID不能为空")
|
||||
current = self._repository.get(workflow_id)
|
||||
if not current:
|
||||
return WorkflowMutationResult(False, "工作流不存在")
|
||||
if not current.trigger_type:
|
||||
values["trigger_type"] = WORKFLOW_TRIGGER_TIMER
|
||||
|
||||
updated = self._repository.stage_update(workflow_id, values)
|
||||
self._commit()
|
||||
self._remove_timer(updated)
|
||||
if (
|
||||
not updated.trigger_type
|
||||
or updated.trigger_type == WORKFLOW_TRIGGER_TIMER
|
||||
) and updated.timer:
|
||||
self._add_timer(updated)
|
||||
self._refresh_event(updated)
|
||||
return WorkflowMutationResult(True, "更新成功")
|
||||
|
||||
def delete(self, workflow_id: int) -> WorkflowMutationResult:
|
||||
"""删除工作流,并在提交后清除缓存和运行时触发器。"""
|
||||
workflow = self._repository.get(workflow_id)
|
||||
if not workflow:
|
||||
return WorkflowMutationResult(False, "工作流不存在")
|
||||
|
||||
self._repository.stage_delete(workflow_id)
|
||||
self._commit()
|
||||
self._delete_cache(workflow_id)
|
||||
if not workflow.trigger_type or workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
|
||||
self._remove_timer(workflow)
|
||||
elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT:
|
||||
self._remove_event(workflow_id, workflow.event_type)
|
||||
return WorkflowMutationResult(True, "删除成功")
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交工作流事务,失败时回滚且不执行后续运行时副作用。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class AsyncWorkflowDefinitionRepository(Protocol):
|
||||
"""工作流创建、复用和重置需要的异步持久化端口。"""
|
||||
|
||||
async def async_get_by_name(self, name: str) -> Optional[Any]:
|
||||
"""按名称读取工作流。"""
|
||||
...
|
||||
|
||||
async def stage_create(self, payload: Mapping[str, Any]) -> Any:
|
||||
"""暂存新工作流。"""
|
||||
...
|
||||
|
||||
async def stage_reset(self, workflow_id: int, reset_count: bool = False) -> Optional[Any]:
|
||||
"""暂存工作流重置。"""
|
||||
...
|
||||
|
||||
async def async_get(self, workflow_id: int) -> Optional[Any]:
|
||||
"""读取指定工作流。"""
|
||||
...
|
||||
|
||||
|
||||
class AsyncUnitOfWork(Protocol):
|
||||
"""异步工作流定义用例使用的事务端口。"""
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
class WorkflowDefinitionCommand:
|
||||
"""协调工作流创建、分享复用和重置的异步写用例。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: AsyncWorkflowDefinitionRepository,
|
||||
unit_of_work: AsyncUnitOfWork,
|
||||
stop_running: Callable[[int], None],
|
||||
delete_cache: Callable[[int], None],
|
||||
report_fork: Optional[Callable[[int], Awaitable[object]]] = None,
|
||||
) -> None:
|
||||
"""保存异步事务和提交后运行时副作用端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._stop_running = stop_running
|
||||
self._delete_cache = delete_cache
|
||||
self._report_fork = report_fork
|
||||
|
||||
async def create(self, payload: Mapping[str, Any]) -> WorkflowMutationResult:
|
||||
"""校验名称并暂存新工作流,提交失败时不产生运行时副作用。"""
|
||||
values = dict(payload)
|
||||
name = values.get("name")
|
||||
if name and await self._repository.async_get_by_name(name):
|
||||
return WorkflowMutationResult(False, "已存在相同名称的工作流")
|
||||
if not values.get("add_time"):
|
||||
values["add_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if not values.get("state"):
|
||||
values["state"] = "P"
|
||||
if not values.get("trigger_type"):
|
||||
values["trigger_type"] = WORKFLOW_TRIGGER_TIMER
|
||||
try:
|
||||
await self._repository.stage_create(values)
|
||||
await self._commit()
|
||||
except Exception:
|
||||
raise
|
||||
return WorkflowMutationResult(True, "创建工作流成功")
|
||||
|
||||
async def fork(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
share_id: Optional[int] = None,
|
||||
) -> WorkflowMutationResult:
|
||||
"""解析共享工作流内容并在提交后更新远程复用次数。"""
|
||||
values = dict(payload)
|
||||
if not values.get("name"):
|
||||
return WorkflowMutationResult(False, "工作流名称不能为空")
|
||||
parsed = {}
|
||||
for field, default, error_message in (
|
||||
("actions", "[]", "actions字段JSON格式错误"),
|
||||
("flows", "[]", "flows字段JSON格式错误"),
|
||||
("context", "{}", "context字段JSON格式错误"),
|
||||
("event_conditions", "{}", "event_conditions字段JSON格式错误"),
|
||||
):
|
||||
raw = values.get(field)
|
||||
try:
|
||||
parsed[field] = json.loads(raw or default)
|
||||
except json.JSONDecodeError:
|
||||
return WorkflowMutationResult(False, error_message)
|
||||
workflow_values = {
|
||||
"name": values["name"],
|
||||
"description": values.get("description"),
|
||||
"timer": values.get("timer"),
|
||||
"trigger_type": values.get("trigger_type") or WORKFLOW_TRIGGER_TIMER,
|
||||
"event_type": values.get("event_type"),
|
||||
"event_conditions": parsed["event_conditions"],
|
||||
"actions": parsed["actions"],
|
||||
"flows": parsed["flows"],
|
||||
"context": parsed["context"],
|
||||
"state": "P",
|
||||
}
|
||||
if await self._repository.async_get_by_name(workflow_values["name"]):
|
||||
return WorkflowMutationResult(False, "已存在相同名称的工作流")
|
||||
try:
|
||||
created = await self._repository.stage_create(workflow_values)
|
||||
await self._commit()
|
||||
except Exception:
|
||||
raise
|
||||
if created and share_id and self._report_fork:
|
||||
try:
|
||||
await self._report_fork(share_id)
|
||||
except Exception:
|
||||
return WorkflowMutationResult(True, "复用成功;共享统计上报失败")
|
||||
return WorkflowMutationResult(True, "复用成功")
|
||||
|
||||
async def reset(self, workflow_id: int) -> WorkflowMutationResult:
|
||||
"""重置工作流并在提交后停止运行态、清除缓存。"""
|
||||
workflow = await self._repository.async_get(workflow_id)
|
||||
if not workflow:
|
||||
return WorkflowMutationResult(False, "工作流不存在")
|
||||
await self._repository.stage_reset(workflow_id, reset_count=True)
|
||||
await self._commit()
|
||||
self._stop_running(workflow_id)
|
||||
self._delete_cache(workflow_id)
|
||||
return WorkflowMutationResult(True)
|
||||
|
||||
async def _commit(self) -> None:
|
||||
"""提交异步事务,失败时回滚且不继续执行运行时副作用。"""
|
||||
try:
|
||||
await self._unit_of_work.commit()
|
||||
except Exception:
|
||||
await self._unit_of_work.rollback()
|
||||
raise
|
||||
Reference in New Issue
Block a user