From eb27b5bfed347d262d391b0992ee05bc768252f9 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Mon, 26 Jan 2026 21:17:18 +0800 Subject: [PATCH] Add data validator for plugin config (#1041) --- backend/common/enums.py | 7 + backend/plugin/code_generator/plugin.toml | 2 +- backend/plugin/config/plugin.toml | 2 +- backend/plugin/core.py | 31 +--- backend/plugin/dict/plugin.toml | 2 +- backend/plugin/email/plugin.toml | 2 +- backend/plugin/errors.py | 10 ++ backend/plugin/notice/plugin.toml | 2 +- backend/plugin/oauth2/plugin.toml | 2 +- backend/plugin/requirements.py | 5 +- backend/plugin/validator.py | 201 ++++++++++++++++++++++ 11 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 backend/plugin/errors.py create mode 100644 backend/plugin/validator.py diff --git a/backend/common/enums.py b/backend/common/enums.py index 5b1cbeac..33ed2ed3 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -110,6 +110,13 @@ class FileType(StrEnum): video = 'video' +class PluginLevelType(StrEnum): + """插件级别类型""" + + app = 'app' + extend = 'extend' + + class PluginType(StrEnum): """插件类型""" diff --git a/backend/plugin/code_generator/plugin.toml b/backend/plugin/code_generator/plugin.toml index f3f72850..4fdd8ee3 100644 --- a/backend/plugin/code_generator/plugin.toml +++ b/backend/plugin/code_generator/plugin.toml @@ -4,7 +4,7 @@ version = '0.1.1' description = '生成通用业务代码' author = 'wu-clan' tags = ['other'] -database = ['mysql', 'pgsql'] +database = ['mysql', 'postgresql'] [app] router = ['v1'] diff --git a/backend/plugin/config/plugin.toml b/backend/plugin/config/plugin.toml index 16e23abd..264fea72 100644 --- a/backend/plugin/config/plugin.toml +++ b/backend/plugin/config/plugin.toml @@ -4,7 +4,7 @@ version = '0.0.2' description = '通常用于动态配置系统参数和前端工程数据展示' author = 'wu-clan' tags = ['other'] -database = ['mysql', 'pgsql'] +database = ['mysql', 'postgresql'] [app] extend = 'admin' diff --git a/backend/plugin/core.py b/backend/plugin/core.py index 28b761b0..1576b6ab 100644 --- a/backend/plugin/core.py +++ b/backend/plugin/core.py @@ -10,24 +10,18 @@ import rtoml from fastapi import APIRouter, Depends, Request -from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType +from backend.common.enums import DataBaseType, PluginLevelType, PrimaryKeyType, StatusType from backend.common.exception import errors from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import RedisCli, redis_client +from backend.plugin.errors import PluginConfigError, PluginInjectError +from backend.plugin.validator import validate_plugin_config from backend.utils.async_helper import run_await from backend.utils.dynamic_import import get_model_objects, import_module_cached -class PluginConfigError(Exception): - """插件信息错误""" - - -class PluginInjectError(Exception): - """插件注入错误""" - - @lru_cache def get_plugins() -> list[str]: """获取插件列表""" @@ -105,7 +99,6 @@ def load_plugin_config(plugin: str) -> dict[str, Any]: def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """解析插件配置""" - extend_plugins = [] app_plugins = [] @@ -123,32 +116,20 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: for plugin in plugins: data = load_plugin_config(plugin) + plugin_type = validate_plugin_config(plugin, data) - plugin_info = data.get('plugin') - if not plugin_info: - raise PluginConfigError(f'插件 {plugin} 配置文件缺少 plugin 配置') - - required_fields = ['summary', 'version', 'description', 'author'] - missing_fields = [field for field in required_fields if field not in plugin_info] - if missing_fields: - raise PluginConfigError(f'插件 {plugin} 配置文件缺少必要字段: {", ".join(missing_fields)}') - - if data.get('api'): - if not data.get('app', {}).get('extend'): - raise PluginConfigError(f'扩展级插件 {plugin} 配置文件缺少 app.extend 配置') + if plugin_type == PluginLevelType.extend: extend_plugins.append(data) else: - if not data.get('app', {}).get('router'): - raise PluginConfigError(f'应用级插件 {plugin} 配置文件缺少 app.router 配置') app_plugins.append(data) # 补充插件信息 + data['plugin']['name'] = plugin plugin_cache_info = run_await(current_redis_client.get)(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}') if plugin_cache_info: data['plugin']['enable'] = json.loads(plugin_cache_info)['plugin']['enable'] else: data['plugin']['enable'] = str(StatusType.enable.value) - data['plugin']['name'] = plugin # 缓存最新插件信息 run_await(current_redis_client.set)( diff --git a/backend/plugin/dict/plugin.toml b/backend/plugin/dict/plugin.toml index 5615aed7..1ab9b5c7 100644 --- a/backend/plugin/dict/plugin.toml +++ b/backend/plugin/dict/plugin.toml @@ -4,7 +4,7 @@ version = '0.0.8' description = '通常用于约束前端工程数据展示' author = 'wu-clan' tags = ['other'] -database = ['mysql', 'pgsql'] +database = ['mysql', 'postgresql'] [app] extend = 'admin' diff --git a/backend/plugin/email/plugin.toml b/backend/plugin/email/plugin.toml index a3df498a..58bb3ed8 100644 --- a/backend/plugin/email/plugin.toml +++ b/backend/plugin/email/plugin.toml @@ -4,7 +4,7 @@ version = '0.0.3' description = '提供邮件发送功能,支持验证码、通知等场景' author = 'wu-clan' tags = ['other'] -database = ['mysql', 'pgsql'] +database = ['mysql', 'postgresql'] [app] router = ['v1'] diff --git a/backend/plugin/errors.py b/backend/plugin/errors.py new file mode 100644 index 00000000..68a7b7a4 --- /dev/null +++ b/backend/plugin/errors.py @@ -0,0 +1,10 @@ +class PluginConfigError(Exception): + """插件信息错误""" + + +class PluginInjectError(Exception): + """插件注入错误""" + + +class PluginInstallError(Exception): + """插件安装错误""" diff --git a/backend/plugin/notice/plugin.toml b/backend/plugin/notice/plugin.toml index 144b0487..5a966225 100644 --- a/backend/plugin/notice/plugin.toml +++ b/backend/plugin/notice/plugin.toml @@ -4,7 +4,7 @@ version = '0.0.2' description = '用于发布系统内部通知、公告' author = 'wu-clan' tags = ['other'] -database = ['mysql', 'pgsql'] +database = ['mysql', 'postgresql'] [app] extend = 'admin' diff --git a/backend/plugin/oauth2/plugin.toml b/backend/plugin/oauth2/plugin.toml index c36e07f8..d3d92ee9 100644 --- a/backend/plugin/oauth2/plugin.toml +++ b/backend/plugin/oauth2/plugin.toml @@ -4,7 +4,7 @@ version = '0.0.11' description = '支持 GitHub、Google 等社交平台登录' author = 'wu-clan' tags = ['auth'] -database = ['mysql', 'pgsql'] +database = ['mysql', 'postgresql'] [app] router = ['v1'] diff --git a/backend/plugin/requirements.py b/backend/plugin/requirements.py index a39437f0..ab89966e 100644 --- a/backend/plugin/requirements.py +++ b/backend/plugin/requirements.py @@ -9,10 +9,7 @@ from starlette.concurrency import run_in_threadpool from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR - - -class PluginInstallError(Exception): - """插件安装错误""" +from backend.plugin.errors import PluginInstallError def get_plugins() -> list[str]: diff --git a/backend/plugin/validator.py b/backend/plugin/validator.py new file mode 100644 index 00000000..7dc6e314 --- /dev/null +++ b/backend/plugin/validator.py @@ -0,0 +1,201 @@ +import warnings + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from backend.common.enums import PluginLevelType +from backend.plugin.errors import PluginConfigError +from backend.utils.pattern_validate import match_string + +# 支持的标签类型 +_VALID_TAGS = frozenset({'ai', 'mcp', 'agent', 'auth', 'storage', 'notification', 'task', 'payment', 'other'}) + +# 支持的数据库类型 +_VALID_DATABASES = frozenset({'mysql', 'postgresql'}) + + +class PluginInfoSchema(BaseModel): + """插件信息模型""" + + icon: str | None = Field(default=None, description='图标路径或链接地址') + summary: str = Field(..., min_length=1, max_length=100, description='摘要') + version: str = Field(..., description='版本号') + description: str = Field(..., min_length=1, max_length=500, description='描述') + author: str = Field(..., min_length=1, max_length=50, description='作者') + tags: list[str] = Field(default_factory=list, description='标签') + database: list[str] = Field(default_factory=list, description='数据库支持') + + @field_validator('version') + @classmethod + def validate_version(cls, v: str) -> str: + """校验版本号格式""" + if not match_string(r'^\d+\.\d+\.\d+$', v): + raise PluginConfigError(f'版本号格式错误,应为 x.y.z 格式,如 1.0.0,当前值: {v}') + return v + + @field_validator('tags') + @classmethod + def validate_tags(cls, v: list[str]) -> list[str]: + """校验标签""" + if v: + invalid_tags = set(v) - _VALID_TAGS + if invalid_tags: + raise PluginConfigError( + f'标签值无效: {", ".join(invalid_tags)},支持的标签: {", ".join(sorted(_VALID_TAGS))}' + ) + return v + + @field_validator('database') + @classmethod + def validate_database(cls, v: list[str]) -> list[str]: + """校验数据库类型""" + if v: + invalid_dbs = set(v) - _VALID_DATABASES + if invalid_dbs: + raise PluginConfigError( + f'数据库类型无效: {", ".join(invalid_dbs)},支持的数据库: {", ".join(sorted(_VALID_DATABASES))}' + ) + return v + + +class AppPluginAppSchema(BaseModel): + """应用级插件 app 配置模型""" + + router: list[str] = Field(..., min_length=1, description='路由器实例列表') + + @field_validator('router') + @classmethod + def validate_router(cls, v: list[str]) -> list[str]: + """校验路由器配置""" + if not v: + raise PluginConfigError('router 配置不能为空') + for router in v: + if not router or not isinstance(router, str): + raise PluginConfigError(f'router 配置项必须为非空字符串,当前值: {router}') + return v + + +class ExtendPluginAppSchema(BaseModel): + """扩展级插件 app 配置模型""" + + extend: str = Field(..., min_length=1, description='扩展的应用文件夹名称') + + +class ApiConfigSchema(BaseModel): + """API 配置模型""" + + prefix: str = Field(..., min_length=1, description='路由前缀') + tags: str = Field(..., min_length=1, description='Swagger 文档标签') + + @field_validator('prefix') + @classmethod + def validate_prefix(cls, v: str) -> str: + """校验路由前缀""" + if not v.startswith('/'): + raise PluginConfigError(f'路由前缀必须以 "/" 开头,当前值: {v}') + if not match_string(r'^/[a-zA-Z0-9_/-]*$', v): + raise PluginConfigError(f'路由前缀格式错误,只能包含字母、数字、下划线、斜杠和连字符,当前值: {v}') + return v + + +class AppPluginConfigSchema(BaseModel): + """应用级插件配置模型""" + + plugin: PluginInfoSchema = Field(..., description='插件信息') + app: AppPluginAppSchema = Field(..., description='应用配置') + settings: dict[str, Any] = Field(default_factory=dict, description='配置项') + + @field_validator('settings') + @classmethod + def validate_settings(cls, v: dict[str, Any]) -> dict[str, Any]: + """校验配置项名称必须全大写""" + if v: + invalid_keys = [key for key in v if not key.isupper()] + if invalid_keys: + raise PluginConfigError(f'settings 配置项名称必须全大写,无效的配置项: {", ".join(invalid_keys)}') + return v + + +class ExtendPluginConfigSchema(BaseModel): + """扩展级插件配置模型""" + + plugin: PluginInfoSchema = Field(..., description='插件信息') + app: ExtendPluginAppSchema = Field(..., description='应用配置') + api: dict[str, ApiConfigSchema] = Field(..., min_length=1, description='接口配置') + settings: dict[str, Any] = Field(default_factory=dict, description='配置项') + + @field_validator('api', mode='before') + @classmethod + def validate_api_config(cls, v: dict[str, Any]) -> dict[str, ApiConfigSchema]: + """校验并转换 API 配置""" + if not v: + raise PluginConfigError('扩展级插件必须包含至少一个 api 配置') + validated_api = {} + for api_name, api_config in v.items(): + if not api_name or not isinstance(api_name, str): + raise PluginConfigError(f'api 配置名称必须为非空字符串,当前值: {api_name}') + if not match_string(r'^[a-zA-Z_][a-zA-Z0-9_]*$', api_name): + raise PluginConfigError( + f'api 配置名称格式错误,必须以字母或下划线开头,只能包含字母、数字和下划线,当前值: {api_name}' + ) + validated_api[api_name] = ApiConfigSchema(**api_config) if isinstance(api_config, dict) else api_config + return validated_api + + @field_validator('settings') + @classmethod + def validate_settings(cls, v: dict[str, Any]) -> dict[str, Any]: + """校验配置项名称必须全大写""" + if v: + invalid_keys = [key for key in v if not key.isupper()] + if invalid_keys: + raise PluginConfigError(f'settings 配置项名称必须全大写,无效的配置项: {", ".join(invalid_keys)}') + return v + + +def validate_plugin_config(plugin_name: str, config: dict[str, Any]) -> PluginLevelType: + """ + 校验插件配置 + + :param plugin_name: 插件名称 + :param config: 插件配置字典 + :return: + """ + is_extend_plugin = 'api' in config + + try: + if is_extend_plugin: + ExtendPluginConfigSchema.model_validate(config) + plugin_level = PluginLevelType.extend + else: + AppPluginConfigSchema.model_validate(config) + plugin_level = PluginLevelType.app + except Exception as e: + error_msg = str(e) + # 格式化 Pydantic 错误信息 + if hasattr(e, 'errors'): + errors = e.errors() + error_details = [] + for error in errors: + loc = '.'.join(str(loc) for loc in error['loc']) + msg = error['msg'] + error_details.append(f'{loc}: {msg}') + error_msg = '; '.join(error_details) + raise PluginConfigError(f'插件 {plugin_name} 配置校验失败: {error_msg}') from e + + # TODO 下个重大版本变更为必填 + plugin_info = config.get('plugin', {}) + if not plugin_info.get('tags'): + warnings.warn( + f"插件 '{plugin_name}' 未配置 'tags' 字段,该字段将在下个重大版本中必填,请及时联系插件作者同步更新", + FutureWarning, + stacklevel=2, + ) + if not plugin_info.get('database'): + warnings.warn( + f"插件 '{plugin_name}' 未配置 'database' 字段,该字段将在下个重大版本中必填,请及时联系插件作者同步更新", + FutureWarning, + stacklevel=2, + ) + + return plugin_level