""" 插件配置保存、重置和运行态重建应用用例。 """ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from typing import Any, ContextManager from app.schemas.exception import PluginMutationRejectedError from app.schemas.plugin import validate_plugin_id @dataclass(frozen=True, slots=True) class PluginConfigResult: """ 描述插件配置写操作是否成功及提示信息。 :param success: 操作是否完成其预期目标 :param message: 操作结果或失败原因的说明,默认空字符串 """ success: bool message: str = "" @dataclass(frozen=True, slots=True) class PluginPurgeScope: """ 彻底清理一个实例时用户选定的删除范围。 四项各自独立,由用户逐项勾选:清理动作不可逆,替用户把范围扩大到他没选的 东西上,比让他多点一下要糟得多。 :param config: 是否清理插件配置 :param plugin_data: 是否清理宿主数据库中的插件键值数据 :param own_database: 是否清理插件自建数据库 :param data_directory: 是否清理插件专属数据目录 """ config: bool = False plugin_data: bool = False own_database: bool = False data_directory: bool = False @property def is_empty(self) -> bool: """ 是否一项都没选。 """ return not any( (self.config, self.plugin_data, self.own_database, self.data_directory) ) @dataclass(frozen=True, slots=True) class PluginPurgeResult: """ 描述彻底清理的执行结果与实际清掉的范围。 :param success: 操作是否完成其预期目标 :param message: 操作结果或失败原因的说明,默认空字符串 :param purged: 本次实际完成清理的范围名称 :param instance_removed: 本次是否移除了插件分身实例 """ success: bool message: str = "" purged: tuple[str, ...] = () instance_removed: bool = False class PluginConfigCommand: """ 协调插件配置持久化、实例初始化和运行时注册刷新。 """ def __init__( self, *, save_config: Callable[[str, dict[str, object], bool], bool], initialize: Callable[[str, dict[str, object]], 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], mutation: Callable[[str], ContextManager[None]], delete_plugin_data_rows: Callable[[str], Any], destroy_own_database: Callable[[str], Any], delete_data_directory: Callable[[str], bool], purge_instance: Callable[[str], bool], is_clone: Callable[[str], bool], purge_invalid_empty_host: Callable[[str], bool] | None = None, ) -> 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 self._mutation = mutation self._delete_plugin_data_rows = delete_plugin_data_rows self._destroy_own_database = destroy_own_database self._delete_data_directory = delete_data_directory self._purge_instance = purge_instance self._is_clone = is_clone self._purge_invalid_empty_host = purge_invalid_empty_host def update(self, plugin_id: str, config: dict[str, object]) -> PluginConfigResult: """ 保存配置并按既有顺序重新初始化实例及运行时注册。 """ try: with self._mutation(f"更新插件 {plugin_id} 配置"): 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) except PluginMutationRejectedError as error: return PluginConfigResult(False, str(error)) def purge(self, instance_id: str, scope: PluginPurgeScope) -> PluginPurgeResult: """ 按用户选定的范围彻底清理一个实例,逐项执行且互不代劳。 与停用的区别在于不可逆:停用只把启用位置假、配置与展示信息原样留着,这里才 真正删除用户数据。因而范围完全由调用方给定,一项没选就直接拒绝,不做「那就 全清吧」这种猜测。 实例 ID 先过格式校验再动任何存储:它会被拼进插件数据目录路径,放行一个带 ``..`` 的 ID 会让后续的整目录删除落到插件数据根之外。校验在最前面,是为了让 非法 ID 在停止实例、删配置行之前就被拒绝,而不是删掉一半才发现。唯一例外是 只选 config 时,可通过数据库专用端口原子回收非法 ID 的空本体行;该路径不 停止实例、不清配置、不触碰文件或自有数据库,带任何设置的行均保留。 分身在清理后连同实例行一并消失——分身的存在本身就由那一行表达;本体的行则 保留,它还承载着这个插件的启用状态与展示覆盖,删掉等于把装着的插件静默停用, 想卸载插件是另一条路径。 自有数据库与数据目录有先后:库文件就落在该目录下,不先销毁句柄直接删目录, Windows 下会因文件占用失败,Linux 下则留下一个仍被写入的已删除文件。选了 删目录就必然先销毁库,不管用户有没有单独勾选它。 这一串动作跨配置行、业务数据表、库文件与目录四种存储,**不是数据库事务**: 中途失败时前面几步已经生效且无法回滚,因而失败回报只承诺「这次没做完」,不 承诺「什么都没动」。目录删除端口在目标不是该实例自己的目录时拒绝执行并抛出 ``ValueError``,这里把它收成失败结果而不是让它变成 500。 :param instance_id: 实例 ID :param scope: 用户选定的删除范围 :return: 执行结果与实际清掉的范围 """ if scope.is_empty: return PluginPurgeResult(False, "请至少选择一项要清理的内容") try: validate_plugin_id(instance_id) except ValueError as error: return self._purge_invalid_host(instance_id, scope, str(error)) try: with self._mutation(f"彻底清理插件实例 {instance_id}"): self._stop(instance_id) purged: list[str] = [] if scope.config: self._delete_config(instance_id, True) purged.append("config") if scope.plugin_data: self._delete_plugin_data_rows(instance_id) purged.append("plugin_data") if scope.own_database or scope.data_directory: self._destroy_own_database(instance_id) if scope.own_database: purged.append("own_database") if scope.data_directory and self._delete_data_directory(instance_id): purged.append("data_directory") instance_removed = ( self._purge_instance(instance_id) if self._is_clone(instance_id) else False ) return PluginPurgeResult( True, purged=tuple(purged), instance_removed=instance_removed, ) except PluginMutationRejectedError as error: return PluginPurgeResult(False, str(error)) except ValueError as error: return PluginPurgeResult(False, f"清理插件实例 {instance_id} 失败:{error}") def _purge_invalid_host( self, instance_id: str, scope: PluginPurgeScope, validation_error: str, ) -> PluginPurgeResult: """ 仅在用户只选配置且数据库确认是纯残留时回收非法实例行。 """ if scope != PluginPurgeScope(config=True) or self._purge_invalid_empty_host is None: return PluginPurgeResult(False, f"实例 ID 非法:{validation_error}") try: with self._mutation(f"清理历史无效插件实例 {instance_id}"): if not self._purge_invalid_empty_host(instance_id): return PluginPurgeResult(False, "历史无效实例不存在或不是可清理的空本体行") return PluginPurgeResult(True, purged=("config",), instance_removed=True) except PluginMutationRejectedError as error: return PluginPurgeResult(False, str(error)) def reset(self, plugin_id: str) -> PluginConfigResult: """ 通知插件补偿后停止实例、删除配置数据并重建运行态。 """ try: with self._mutation(f"重置插件 {plugin_id} 配置和数据"): 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) except PluginMutationRejectedError as error: return PluginConfigResult(False, str(error))