From e44e4245f584b4e6d5913f5d9d64d25ef63d0ad3 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sat, 4 Apr 2026 12:38:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pytests/test_plugin_config_runtime.py | 128 +++++++++-- src/plugin_runtime/runner/runner_main.py | 249 ++++++++++++++++++++-- src/webui/routers/plugin/config_routes.py | 42 +++- 3 files changed, 382 insertions(+), 37 deletions(-) diff --git a/pytests/test_plugin_config_runtime.py b/pytests/test_plugin_config_runtime.py index 160936a1..b84bafb8 100644 --- a/pytests/test_plugin_config_runtime.py +++ b/pytests/test_plugin_config_runtime.py @@ -26,6 +26,8 @@ from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest class _DemoConfigPlugin: """用于测试 Runner 配置归一化流程的伪插件。""" + _config_version: str = "2.0.0" + def __init__(self) -> None: """初始化测试插件状态。""" @@ -43,7 +45,8 @@ class _DemoConfigPlugin: current_config = dict(config_data or {}) plugin_section = dict(current_config.get("plugin", {})) - changed = "retry_count" not in plugin_section + changed = "retry_count" not in plugin_section or "config_version" not in plugin_section + plugin_section.setdefault("config_version", self._config_version) plugin_section.setdefault("enabled", True) plugin_section.setdefault("retry_count", 3) return {"plugin": plugin_section}, changed @@ -64,7 +67,7 @@ class _DemoConfigPlugin: Dict[str, Any]: 默认配置字典。 """ - return {"plugin": {"enabled": True, "retry_count": 3}} + return {"plugin": {"config_version": self._config_version, "enabled": True, "retry_count": 3}} def get_webui_config_schema( self, @@ -131,6 +134,7 @@ class _StrictConfigPlugin: current_config = dict(config_data or {}) plugin_section = dict(current_config.get("plugin", {})) + plugin_section.setdefault("config_version", "2.0.0") retry_count = int(plugin_section.get("retry_count", 0)) if retry_count < 0: raise ValueError("重试次数不能小于 0") @@ -146,6 +150,15 @@ class _StrictConfigPlugin: del config + def get_default_config(self) -> Dict[str, Any]: + """返回测试插件的默认配置。 + + Returns: + Dict[str, Any]: 默认配置字典。 + """ + + return {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 0}} + def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None: """Runner 注入配置时应自动补齐并落盘 config.toml。""" @@ -158,19 +171,22 @@ def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> Non ) meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) - runner._apply_plugin_config(cast(Any, meta), config_data={"plugin": {"enabled": False}}) + runner._apply_plugin_config( + cast(Any, meta), + config_data={"plugin": {"config_version": "2.0.0", "enabled": False}}, + ) config_path = tmp_path / "config.toml" assert config_path.exists() - assert plugin.received_config == {"plugin": {"enabled": False, "retry_count": 3}} + assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} with config_path.open("rb") as handle: saved_config = tomllib.load(handle) - assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} + assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) -> None: - """Runner 补齐配置时应尽量保留现有 config.toml 注释。""" + """Runner 在版本升级时应尽量保留现有 config.toml 注释。""" plugin = _DemoConfigPlugin() runner = PluginRunner( @@ -181,7 +197,7 @@ def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) config_path = tmp_path / "config.toml" config_path.write_text( - '# 插件配置头注释\n[plugin]\nenabled = false # 启用开关注释\n', + '# 插件配置头注释\n[plugin]\nconfig_version = "1.0.0"\nenabled = false # 启用开关注释\n', encoding="utf-8", ) @@ -193,7 +209,44 @@ def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) with config_path.open("rb") as handle: saved_config = tomllib.load(handle) - assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} + assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} + + +def test_runner_apply_plugin_config_same_version_does_not_rewrite_file(tmp_path: Path) -> None: + """Runner 在配置版本未变化时不应仅因补齐默认值而重写文件。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) + config_path = tmp_path / "config.toml" + original_config_text = '# 原始注释\n[plugin]\nconfig_version = "2.0.0"\nenabled = false\n' + config_path.write_text(original_config_text, encoding="utf-8") + + runner._apply_plugin_config(cast(Any, meta)) + + assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} + assert config_path.read_text(encoding="utf-8") == original_config_text + + +def test_runner_apply_plugin_config_requires_config_version(tmp_path: Path) -> None: + """Runner 应拒绝缺少配置版本号的插件配置文件。""" + + plugin = _DemoConfigPlugin() + runner = PluginRunner( + host_address="ipc://unused", + session_token="session-token", + plugin_dirs=[], + ) + meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin) + config_path = tmp_path / "config.toml" + config_path.write_text("[plugin]\nenabled = true\n", encoding="utf-8") + + with pytest.raises(ValueError, match="config_version"): + runner._apply_plugin_config(cast(Any, meta)) def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None: @@ -246,14 +299,18 @@ async def test_runner_validate_plugin_config_handler_returns_normalized_config(m message_type=MessageType.REQUEST, method="plugin.validate_config", plugin_id="demo.plugin", - payload=ValidatePluginConfigPayload(config_data={"plugin": {"enabled": False}}).model_dump(), + payload=ValidatePluginConfigPayload( + config_data={"plugin": {"config_version": "2.0.0", "enabled": False}} + ).model_dump(), ) response = await runner._handle_validate_plugin_config(envelope) assert response.error is None assert response.payload["success"] is True - assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} + assert response.payload["normalized_config"] == { + "plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3} + } @pytest.mark.asyncio @@ -308,8 +365,12 @@ async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin( assert response.error is None assert response.payload["success"] is True assert response.payload["enabled"] is False - assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}} - assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}} + assert response.payload["normalized_config"] == { + "plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3} + } + assert response.payload["default_config"] == { + "plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3} + } assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")] @@ -333,7 +394,9 @@ async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_co message_type=MessageType.REQUEST, method="plugin.validate_config", plugin_id="demo.plugin", - payload=ValidatePluginConfigPayload(config_data={"plugin": {"retry_count": -1}}).model_dump(), + payload=ValidatePluginConfigPayload( + config_data={"plugin": {"config_version": "2.0.0", "retry_count": -1}} + ).model_dump(), ) response = await runner._handle_validate_plugin_config(envelope) @@ -363,10 +426,37 @@ async def test_update_plugin_config_prefers_runtime_validation( """ assert plugin_id == "demo.plugin" - assert config_data == {"plugin": {"enabled": False}} - return {"plugin": {"enabled": False, "retry_count": 3}} + assert config_data == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} + return {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} - fake_runtime_manager = SimpleNamespace(validate_plugin_config=_mock_validate_plugin_config) + async def _mock_inspect_plugin_config( + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, + ) -> SimpleNamespace | None: + """返回运行时配置快照。 + + Args: + plugin_id: 插件 ID。 + config_data: 可选配置。 + use_provided_config: 是否使用传入配置。 + + Returns: + SimpleNamespace | None: 运行时配置快照。 + """ + + del config_data, use_provided_config + if plugin_id != "demo.plugin": + return None + return SimpleNamespace( + normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}} + ) + + fake_runtime_manager = SimpleNamespace( + inspect_plugin_config=_mock_inspect_plugin_config, + validate_plugin_config=_mock_validate_plugin_config, + ) monkeypatch.setattr( "src.webui.routers.plugin.config_routes.require_plugin_token", @@ -390,7 +480,7 @@ async def test_update_plugin_config_prefers_runtime_validation( assert response["success"] is True with config_path.open("rb") as handle: saved_config = tomllib.load(handle) - assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}} + assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}} @pytest.mark.asyncio @@ -432,7 +522,7 @@ async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin "sections": {"plugin": {"fields": {}}}, "layout": {"type": "auto", "tabs": []}, }, - normalized_config={"plugin": {"enabled": True, "retry_count": 3}}, + normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}}, enabled=True, ) @@ -458,6 +548,6 @@ async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin assert schema_response["schema"]["plugin_id"] == "demo.plugin" assert config_response == { "success": True, - "config": {"plugin": {"enabled": True, "retry_count": 3}}, + "config": {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}}, "message": "配置文件不存在,已返回默认配置", } diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index 157bdecf..e485567f 100644 --- a/src/plugin_runtime/runner/runner_main.py +++ b/src/plugin_runtime/runner/runner_main.py @@ -10,6 +10,7 @@ """ from collections.abc import Mapping +from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast @@ -28,6 +29,7 @@ import tomllib import tomlkit from src.common.logger import get_console_handler, get_logger, initialize_logging +from src.config.config_utils import compare_versions from src.plugin_runtime import ( ENV_BLOCKED_PLUGIN_REASONS, ENV_EXTERNAL_PLUGIN_IDS, @@ -154,6 +156,122 @@ class PluginActivationStatus(str, Enum): FAILED = "failed" +@dataclass(frozen=True) +class PluginConfigNormalizationResult: + """描述插件配置归一化结果。""" + + normalized_config: Dict[str, Any] + changed: bool + should_persist: bool + + +class PluginConfigVersionError(ValueError): + """插件配置版本不合法时抛出的异常。""" + + +def _deep_copy_plugin_config_value(value: Any) -> Any: + """递归复制插件配置值。 + + Args: + value: 待复制的任意配置值。 + + Returns: + Any: 深复制后的配置值。 + """ + + if isinstance(value, Mapping): + return _deep_copy_plugin_config_mapping(value) + if isinstance(value, list): + return [_deep_copy_plugin_config_value(item) for item in value] + return value + + +def _deep_copy_plugin_config_mapping(value: Mapping[str, Any]) -> Dict[str, Any]: + """递归复制插件配置字典。 + + Args: + value: 待复制的插件配置映射。 + + Returns: + Dict[str, Any]: 深复制后的插件配置字典。 + """ + + return {str(key): _deep_copy_plugin_config_value(item) for key, item in value.items()} + + +def _overlay_plugin_config_fields(target: Dict[str, Any], source: Mapping[str, Any]) -> None: + """将旧配置中的已有字段覆盖到新配置骨架中。 + + Args: + target: 以最新默认配置构造出的目标配置字典。 + source: 旧版本配置字典。 + """ + + for key, source_value in source.items(): + if key not in target: + continue + if key == "config_version": + continue + + target_value = target[key] + if isinstance(target_value, dict) and isinstance(source_value, Mapping): + _overlay_plugin_config_fields(target_value, source_value) + continue + + target[key] = _deep_copy_plugin_config_value(source_value) + + +def extract_plugin_config_version(config_data: Mapping[str, Any]) -> str: + """提取插件配置中的版本号。 + + Args: + config_data: 插件配置字典。 + + Returns: + str: ``plugin.config_version`` 的规范化字符串值。 + + Raises: + PluginConfigVersionError: 当缺少 ``[plugin]`` 配置节或 ``config_version`` + 字段为空时抛出。 + """ + + plugin_section = config_data.get("plugin") + if not isinstance(plugin_section, Mapping): + raise PluginConfigVersionError( + "插件配置文件缺少 [plugin] 配置节,且必须提供 plugin.config_version 版本号" + ) + + version_value = plugin_section.get("config_version") + normalized_version = str(version_value or "").strip() + if not normalized_version: + raise PluginConfigVersionError( + "插件配置文件缺少 plugin.config_version 版本号,当前版本策略不再兼容无版本配置" + ) + return normalized_version + + +def rebuild_plugin_config_data( + default_config: Mapping[str, Any], + current_config: Mapping[str, Any], +) -> Dict[str, Any]: + """基于默认结构重建插件配置。 + + 该方法用于版本升级场景:以最新默认配置为骨架,仅迁移仍然存在的旧字段值, + 从而达到“补齐新增字段、移除废弃字段、保留用户已有值”的效果。 + + Args: + default_config: 最新默认配置内容。 + current_config: 旧版本配置内容。 + + Returns: + Dict[str, Any]: 按最新结构重建后的配置字典。 + """ + + rebuilt_config = _deep_copy_plugin_config_mapping(default_config) + _overlay_plugin_config_fields(rebuilt_config, current_config) + return rebuilt_config + + def _install_shutdown_signal_handlers( mark_runner_shutting_down: Callable[[], None], loop: Optional[asyncio.AbstractEventLoop] = None, @@ -414,12 +532,19 @@ class PluginRunner: Dict[str, Any]: 归一化后的当前插件配置。 """ instance = meta.instance - raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir) - plugin_config, should_persist = self._normalize_plugin_config(instance, raw_config) - config_path = Path(meta.plugin_dir) / "config.toml" default_config = self._get_plugin_default_config(instance) + raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir, meta.plugin_id) + normalization_result = self._normalize_plugin_config( + instance, + raw_config, + default_config=default_config, + suppress_errors=False, + enforce_version=True, + ) + plugin_config = normalization_result.normalized_config + config_path = Path(meta.plugin_dir) / "config.toml" should_initialize_file = not config_path.exists() and bool(default_config) - if should_persist or should_initialize_file: + if normalization_result.should_persist or should_initialize_file: self._save_plugin_config(meta.plugin_dir, plugin_config) if hasattr(instance, "set_plugin_config"): try: @@ -433,30 +558,105 @@ class PluginRunner: instance: object, config_data: Optional[Dict[str, Any]], *, + default_config: Optional[Dict[str, Any]] = None, suppress_errors: bool = True, - ) -> Tuple[Dict[str, Any], bool]: + enforce_version: bool = True, + ) -> PluginConfigNormalizationResult: """对插件配置做统一归一化处理。 Args: instance: 插件实例。 config_data: 原始配置数据。 + default_config: 插件声明的默认配置。 suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。 + enforce_version: 是否强制执行 ``plugin.config_version`` 版本检查。 Returns: - Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否需要回写文件。 + PluginConfigNormalizationResult: 归一化结果、是否发生变更以及是否应写回文件。 """ - normalized_config = dict(config_data or {}) - if not hasattr(instance, "normalize_plugin_config"): - return normalized_config, False + raw_config = dict(config_data or {}) + latest_default_config = default_config if default_config is not None else self._get_plugin_default_config(instance) + config_for_normalize = rebuild_plugin_config_data(raw_config, {}) + should_persist = False try: - return cast(_ConfigAwarePlugin, instance).normalize_plugin_config(normalized_config) + if latest_default_config: + if enforce_version: + config_for_normalize, should_persist = self._prepare_plugin_config_for_version_update( + raw_config=raw_config, + default_config=latest_default_config, + ) + elif not raw_config: + config_for_normalize = rebuild_plugin_config_data(latest_default_config, {}) + except Exception as exc: + if not suppress_errors: + raise + logger.warning(f"插件配置版本检查失败,将回退为原始配置: {exc}") + return PluginConfigNormalizationResult( + normalized_config=raw_config, + changed=False, + should_persist=False, + ) + + if not hasattr(instance, "normalize_plugin_config"): + return PluginConfigNormalizationResult( + normalized_config=config_for_normalize, + changed=config_for_normalize != raw_config, + should_persist=should_persist, + ) + + try: + normalized_config, normalized_changed = cast(_ConfigAwarePlugin, instance).normalize_plugin_config( + config_for_normalize + ) except Exception as exc: if not suppress_errors: raise logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}") - return normalized_config, False + return PluginConfigNormalizationResult( + normalized_config=raw_config, + changed=False, + should_persist=False, + ) + + return PluginConfigNormalizationResult( + normalized_config=normalized_config, + changed=normalized_changed or normalized_config != raw_config, + should_persist=should_persist, + ) + + @staticmethod + def _prepare_plugin_config_for_version_update( + raw_config: Mapping[str, Any], + default_config: Mapping[str, Any], + ) -> Tuple[Dict[str, Any], bool]: + """基于配置版本决定是否需要重建插件配置。 + + Args: + raw_config: 当前磁盘上的插件配置。 + default_config: 插件最新默认配置。 + + Returns: + Tuple[Dict[str, Any], bool]: 用于后续归一化的配置副本,以及是否需要写回文件。 + + Raises: + PluginConfigVersionError: 当默认配置或当前配置缺少版本号时抛出。 + """ + + if not default_config: + return rebuild_plugin_config_data(raw_config, {}), False + + latest_version = extract_plugin_config_version(default_config) + if not raw_config: + return rebuild_plugin_config_data(default_config, {}), False + + current_version = extract_plugin_config_version(raw_config) + if compare_versions(current_version, latest_version): + logger.info(f"检测到插件配置版本升级: {current_version} -> {latest_version}") + return rebuild_plugin_config_data(default_config, raw_config), True + + return rebuild_plugin_config_data(raw_config, {}), False @staticmethod def _merge_plugin_config_document(target: Any, source: Any) -> None: @@ -642,6 +842,7 @@ class PluginRunner: config_data: Optional[Dict[str, Any]] = None, use_provided_config: bool = False, suppress_errors: bool = True, + enforce_version: bool = True, ) -> InspectPluginConfigResultPayload: """解析插件代码定义的配置元数据。 @@ -650,6 +851,7 @@ class PluginRunner: config_data: 可选的配置内容。 use_provided_config: 是否优先使用传入的配置内容。 suppress_errors: 是否在归一化失败时回退原始配置。 + enforce_version: 是否强制校验 ``plugin.config_version``。 Returns: InspectPluginConfigResultPayload: 结构化解析结果。 @@ -659,14 +861,18 @@ class PluginRunner: if use_provided_config and config_data is None: raw_config = {} - normalized_config, changed = self._normalize_plugin_config( + default_config = self._get_plugin_default_config(meta.instance) + normalization_result = self._normalize_plugin_config( meta.instance, raw_config, + default_config=default_config, suppress_errors=suppress_errors, + enforce_version=enforce_version, ) - default_config = self._get_plugin_default_config(meta.instance) + normalized_config = normalization_result.normalized_config + changed = normalization_result.changed if not normalized_config and not raw_config and default_config: - normalized_config = dict(default_config) + normalized_config = rebuild_plugin_config_data(default_config, {}) changed = True return InspectPluginConfigResultPayload( @@ -946,7 +1152,16 @@ class PluginRunner: PluginActivationStatus: 插件激活结果。 """ self._inject_context(meta.plugin_id, meta.instance) - plugin_config = self._apply_plugin_config(meta) + try: + plugin_config = self._apply_plugin_config(meta) + except PluginConfigVersionError as exc: + logger.error(f"插件 {meta.plugin_id} 配置版本非法: {exc}") + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED + except Exception as exc: + logger.error(f"插件 {meta.plugin_id} 配置加载失败: {exc}", exc_info=True) + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED if not self._is_plugin_enabled(plugin_config): logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活") self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) @@ -1604,7 +1819,8 @@ class PluginRunner: meta, config_data=payload.config_data, use_provided_config=payload.use_provided_config, - suppress_errors=True, + suppress_errors=payload.use_provided_config, + enforce_version=not payload.use_provided_config, ) except Exception as exc: return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) @@ -1643,6 +1859,7 @@ class PluginRunner: config_data=payload.config_data, use_provided_config=True, suppress_errors=False, + enforce_version=True, ) except Exception as exc: return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) diff --git a/src/webui/routers/plugin/config_routes.py b/src/webui/routers/plugin/config_routes.py index 9c307085..66a89250 100644 --- a/src/webui/routers/plugin/config_routes.py +++ b/src/webui/routers/plugin/config_routes.py @@ -3,8 +3,8 @@ from pathlib import Path from typing import Any, Dict, Optional, cast -import tomlkit from fastapi import APIRouter, Cookie, HTTPException +import tomlkit from src.common.logger import get_logger from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload @@ -13,6 +13,7 @@ from src.webui.utils.toml_utils import save_toml_with_format from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest from .support import ( backup_file, + deep_merge, find_plugin_path_by_id, get_plugin_config_path, normalize_dotted_keys, @@ -26,6 +27,15 @@ router = APIRouter() def _to_builtin_data(obj: Any) -> Any: + """将 TOML 对象递归转换为内建 Python 数据。 + + Args: + obj: 原始对象。 + + Returns: + Any: 转换后的内建数据结构。 + """ + if hasattr(obj, "unwrap"): try: obj = obj.unwrap() @@ -39,6 +49,22 @@ def _to_builtin_data(obj: Any) -> Any: return obj +def _merge_plugin_config_patch(base_config: Dict[str, Any], patch_config: Dict[str, Any]) -> Dict[str, Any]: + """以现有配置为基线合并本次插件配置改动。 + + Args: + base_config: 当前完整配置。 + patch_config: 本次提交的局部配置改动。 + + Returns: + Dict[str, Any]: 合并后的完整配置。 + """ + + merged_config = cast(Dict[str, Any], _to_builtin_data(base_config)) + deep_merge(merged_config, patch_config) + return merged_config + + def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]: """根据当前配置内容自动推断一个兜底 Schema。 @@ -489,7 +515,19 @@ async def update_plugin_config( config_data = request.config or {} if isinstance(config_data, dict): - config_data = normalize_dotted_keys(config_data) + config_patch = normalize_dotted_keys(config_data) + runtime_snapshot = None + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 保存前配置检查失败,将回退到磁盘内容: {exc}") + + base_config = ( + dict(runtime_snapshot.normalized_config) + if runtime_snapshot is not None + else _load_plugin_config_from_disk(plugin_path) + ) + config_data = _merge_plugin_config_patch(base_config, config_patch) runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data) if isinstance(runtime_validated_config, dict): config_data = runtime_validated_config