feat: add runtime validation for plugin configurations

- Introduced a new method `validate_plugin_config` in `PluginRuntimeManager` to validate plugin configurations at runtime.
- Implemented the `_normalize_plugin_config` method in `PluginRunner` to normalize and persist plugin configurations.
- Enhanced the `PluginRunner` to handle configuration validation requests and return normalized configurations.
- Updated the WebUI routes to utilize runtime validation for plugin configurations, ensuring that configurations are validated and normalized before saving.
- Added tests for runtime configuration validation and normalization processes, including handling of invalid configurations.
This commit is contained in:
DrSmoothl
2026-04-01 19:39:55 +08:00
parent efb84df768
commit 7b3c12ba02
9 changed files with 946 additions and 65 deletions

View File

@@ -1,3 +1,5 @@
"""插件配置相关 WebUI 路由。"""
import json
from typing import Any, Dict, Optional, cast
@@ -5,13 +7,12 @@ import tomlkit
from fastapi import APIRouter, Cookie, HTTPException
from src.common.logger import get_logger
from src.plugin_runtime.component_query import component_query_service
from src.webui.utils.toml_utils import save_toml_with_format
from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest
from .support import (
backup_file,
coerce_types,
find_plugin_instance,
find_plugin_path_by_id,
normalize_dotted_keys,
require_plugin_token,
@@ -24,6 +25,16 @@ router = APIRouter()
def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]:
"""根据当前配置内容自动推断一个兜底 Schema。
Args:
plugin_id: 插件 ID。
current_config: 当前配置对象。
Returns:
Dict[str, Any]: 可供前端渲染的兜底 Schema。
"""
schema: Dict[str, Any] = {
"plugin_id": plugin_id,
"plugin_info": {
@@ -119,15 +130,123 @@ def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Di
return schema
def _coerce_scalar_value(field_schema: Dict[str, Any], value: Any) -> Any:
"""根据字段 Schema 规范化单个字段值。
Args:
field_schema: 单个字段 Schema。
value: 当前字段值。
Returns:
Any: 规范化后的字段值。
"""
field_type = str(field_schema.get("type", "") or "").lower()
if field_type == "boolean" and isinstance(value, str):
normalized_value = value.strip().lower()
if normalized_value in {"1", "true", "yes", "on"}:
return True
if normalized_value in {"0", "false", "no", "off"}:
return False
if field_type == "integer" and isinstance(value, str):
try:
return int(value)
except ValueError:
return value
if field_type == "number" and isinstance(value, str):
try:
return float(value)
except ValueError:
return value
if field_type == "array" and isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value
def _coerce_config_by_plugin_schema(schema: Dict[str, Any], config_data: Dict[str, Any]) -> None:
"""根据插件配置 Schema 就地规范化配置值类型。
Args:
schema: 插件配置 Schema。
config_data: 待规范化的配置字典。
"""
sections = schema.get("sections")
if not isinstance(sections, dict):
return
for section_name, section_schema in sections.items():
if not isinstance(section_schema, dict):
continue
if section_name not in config_data or not isinstance(config_data[section_name], dict):
continue
section_fields = section_schema.get("fields")
if not isinstance(section_fields, dict):
continue
section_config = cast(Dict[str, Any], config_data[section_name])
for field_name, field_schema in section_fields.items():
if field_name not in section_config or not isinstance(field_schema, dict):
continue
section_config[field_name] = _coerce_scalar_value(field_schema, section_config[field_name])
def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument:
"""将普通字典转换为 TOML 文档对象。
Args:
config_data: 原始配置字典。
Returns:
tomlkit.TOMLDocument: 解析后的 TOML 文档。
"""
if not config_data:
return tomlkit.document()
return tomlkit.parse(tomlkit.dumps(config_data))
async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None:
"""通过插件运行时对配置进行校验。
Args:
plugin_id: 插件 ID。
config_data: 待校验的配置内容。
Returns:
Dict[str, Any] | None: 校验成功时返回规范化后的配置;若运行时不可用则返回
``None``,由调用方自行回退到静态 Schema 方案。
Raises:
ValueError: 插件运行时明确判定配置非法时抛出。
"""
from src.plugin_runtime.integration import get_plugin_runtime_manager
runtime_manager = get_plugin_runtime_manager()
return await runtime_manager.validate_plugin_config(plugin_id, config_data)
@router.get("/config/{plugin_id}/schema")
async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
"""按插件 ID 返回配置 Schema。
Args:
plugin_id: 插件 ID。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 包含 Schema 的响应字典。
"""
require_plugin_token(maibot_session)
logger.info(f"获取插件配置 Schema: {plugin_id}")
try:
plugin_instance = find_plugin_instance(plugin_id)
if plugin_instance and hasattr(plugin_instance, "get_webui_config_schema"):
return {"success": True, "schema": plugin_instance.get_webui_config_schema()}
registration_schema = component_query_service.get_plugin_config_schema(plugin_id)
if isinstance(registration_schema, dict) and registration_schema:
return {"success": True, "schema": registration_schema}
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
@@ -141,7 +260,7 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str]
except Exception as e:
logger.warning(f"读取 config_schema.json 失败,回退到自动推断: {e}")
current_config: Any = {}
current_config: Any = component_query_service.get_plugin_default_config(plugin_id) or {}
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
@@ -157,6 +276,16 @@ async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str]
@router.get("/config/{plugin_id}/raw")
async def get_plugin_config_raw(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
"""获取插件原始 TOML 配置内容。
Args:
plugin_id: 插件 ID。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 包含原始配置文本的响应字典。
"""
require_plugin_token(maibot_session)
logger.info(f"获取插件原始配置: {plugin_id}")
@@ -184,6 +313,17 @@ async def update_plugin_config_raw(
request: UpdatePluginRawConfigRequest,
maibot_session: Optional[str] = Cookie(None),
) -> Dict[str, Any]:
"""更新插件原始 TOML 配置内容。
Args:
plugin_id: 插件 ID。
request: 原始配置更新请求。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 更新结果。
"""
require_plugin_token(maibot_session)
logger.info(f"更新插件原始配置: {plugin_id}")
@@ -216,6 +356,16 @@ async def update_plugin_config_raw(
@router.get("/config/{plugin_id}")
async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
"""获取插件配置字典。
Args:
plugin_id: 插件 ID。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 当前配置响应。
"""
require_plugin_token(maibot_session)
logger.info(f"获取插件配置: {plugin_id}")
@@ -226,6 +376,9 @@ async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cook
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
if not config_path.exists():
default_config = component_query_service.get_plugin_default_config(plugin_id)
if isinstance(default_config, dict):
return {"success": True, "config": default_config, "message": "配置文件不存在,已返回默认配置"}
return {"success": True, "config": {}, "message": "配置文件不存在"}
with open(config_path, "r", encoding="utf-8") as file_obj:
@@ -244,17 +397,31 @@ async def update_plugin_config(
request: UpdatePluginConfigRequest,
maibot_session: Optional[str] = Cookie(None),
) -> Dict[str, Any]:
"""更新插件结构化配置。
Args:
plugin_id: 插件 ID。
request: 结构化配置更新请求。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 更新结果。
"""
require_plugin_token(maibot_session)
logger.info(f"更新插件配置: {plugin_id}")
try:
plugin_instance = find_plugin_instance(plugin_id)
config_data = request.config or {}
if plugin_instance and isinstance(config_data, dict):
if isinstance(config_data, dict):
config_data = normalize_dotted_keys(config_data)
if isinstance(plugin_instance.config_schema, dict):
coerce_types(plugin_instance.config_schema, config_data)
runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data)
if isinstance(runtime_validated_config, dict):
config_data = runtime_validated_config
else:
plugin_schema = component_query_service.get_plugin_config_schema(plugin_id)
if isinstance(plugin_schema, dict) and plugin_schema:
_coerce_config_by_plugin_schema(plugin_schema, config_data)
plugin_path = find_plugin_path_by_id(plugin_id)
if plugin_path is None:
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
@@ -267,6 +434,8 @@ async def update_plugin_config(
save_toml_with_format(config_data, str(config_path))
logger.info(f"已更新插件配置: {plugin_id}")
return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HTTPException:
raise
except Exception as e:
@@ -276,6 +445,16 @@ async def update_plugin_config(
@router.post("/config/{plugin_id}/reset")
async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
"""重置插件配置文件。
Args:
plugin_id: 插件 ID。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 重置结果。
"""
require_plugin_token(maibot_session)
logger.info(f"重置插件配置: {plugin_id}")
@@ -300,6 +479,16 @@ async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Co
@router.post("/config/{plugin_id}/toggle")
async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]:
"""切换插件启用状态。
Args:
plugin_id: 插件 ID。
maibot_session: 当前会话令牌。
Returns:
Dict[str, Any]: 切换结果。
"""
require_plugin_token(maibot_session)
logger.info(f"切换插件状态: {plugin_id}")
@@ -309,7 +498,8 @@ async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(N
raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}")
config_path = resolve_plugin_file_path(plugin_path, "config.toml")
config = tomlkit.document()
default_config = component_query_service.get_plugin_default_config(plugin_id)
config = _build_toml_document(default_config if isinstance(default_config, dict) else {})
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as file_obj:
config = tomlkit.load(file_obj)