Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/management/service/config.py
T
insistence 2a055ba648 feat: 新增插件系统 (#112)
* feat: 初始化插件系统

* refactor: 收口插件系统运行时重构

* perf: 优化插件系统类型提示

* fix&perf: 修复和优化插件系统

* fix: 修复gitignore规则误忽略插件文件的问题

* fix: 修复运行时插件根路径算错的问题

* fix: 加强插件发现和路由注册的防护措施

* revert: 回滚定时任务白名单

* fix: 移除未使用的应用路由注册探测

* revert: 恢复部分代码

* perf: 优化插件系统

* docs: 新增插件开发文档

* perf: 优化插件管理模块

* perf: 提升插件系统核心能力

* refactor: 重构生命周期 step runner

* fix: 修复lint错误

* test: 清理测试用例

* test: 调整测试目录名称

* fix: 修复前后端目录硬编码的问题

* fix: 修复插件系统安全性缺口

* refactor: 重新设计插件生命周期 Migration 事务与回滚

* perf: 优化插件系统边界问题

* refactor: 重构当前插件系统的依赖体系设计

* perf: 优化代码

* perf: 优化代码

* fix: 修复代码合并问题

* fix: 修复bug

* perf: 优化代码

* perf&fix: 优化代码和修复bug

* docs: 优化文档格式

* feat: 适配Vue2版本

* docs: 更新README文档

* fix: 修复ruff lint错误

* chore: 更新后端依赖文件
2026-07-28 20:35:18 +08:00

255 lines
9.7 KiB
Python

import json
import re
from typing import cast
from plugins.core.management.entity.vo.schemas import PluginConfigModel, PluginConfigValueModel
from plugins.core.manifest.schema import PluginConfigItemManifest
from plugins.core.types import PluginConfigValue
from utils.crypto_util import CryptoUtil
from utils.log_util import logger
class PluginConfigManager:
"""
插件配置管理器。
使用 Manager 模式集中处理配置声明、配置值序列化、默认值安装和脱敏输出。
"""
MASK_VALUE = '******'
ENCRYPTED_PREFIX = 'enc:v1:'
@classmethod
def build_config_model(cls, plugin_id: str, item: PluginConfigItemManifest) -> PluginConfigModel:
"""
根据 manifest 配置项构建数据库配置模型。
:param plugin_id: 插件ID
:param item: 插件配置项声明
:return: 插件配置数据库模型
"""
serialized_default = cls.serialize_config_value(item.default, secret=item.secret)
return PluginConfigModel(
pluginId=plugin_id,
configKey=item.key,
configLabel=item.label,
configType=item.type,
configValue=serialized_default,
defaultValue=serialized_default,
required='0' if item.required else '1',
secret='0' if item.secret else '1',
options=cls.serialize_value([option.model_dump() for option in item.options]),
description=item.description,
)
@classmethod
def build_config_value(
cls,
config: object,
manifest_item: PluginConfigItemManifest | None = None,
*,
reveal_secret: bool = False,
) -> PluginConfigValueModel:
"""
构建面向插件管理接口和运行时负载的插件配置值。
:param config: 数据库配置对象
:param manifest_item: manifest 配置项声明
:param reveal_secret: 是否展示敏感配置原值
:return: 插件配置值模型
"""
secret = getattr(config, 'secret', '1') == '0'
raw_value = getattr(config, 'config_value', None)
config_type = getattr(config, 'config_type', 'string')
value = cls.deserialize_config_value(raw_value, config_type, secret=secret)
if secret and not reveal_secret and value not in (None, ''):
value = cls.MASK_VALUE
default = cls.deserialize_config_value(getattr(config, 'default_value', None), config_type, secret=secret)
if secret and not reveal_secret and default not in (None, ''):
default = cls.MASK_VALUE
config_key = config.config_key
return PluginConfigValueModel(
key=config_key,
label=getattr(config, 'config_label', None) or getattr(manifest_item, 'label', None),
type=getattr(config, 'config_type', None) or getattr(manifest_item, 'type', 'string'),
value=value,
default=default,
required=getattr(config, 'required', '1') == '0',
secret=secret,
group=getattr(manifest_item, 'group', 'default'),
order=getattr(manifest_item, 'order', 0),
placeholder=getattr(manifest_item, 'placeholder', ''),
min=getattr(manifest_item, 'min_value', None),
max=getattr(manifest_item, 'max_value', None),
pattern=getattr(manifest_item, 'pattern', None),
options=cls.deserialize_options(getattr(config, 'options', None)),
description=getattr(config, 'description', None) or getattr(manifest_item, 'description', None),
)
@classmethod
def migrate_config_secret_storage(
cls,
config: object,
item: PluginConfigItemManifest,
) -> str | None:
"""
在 manifest 修改 secret 属性时迁移已有配置值的存储格式。
:param config: 已有数据库配置对象
:param item: 当前 manifest 配置声明
:return: 使用新 secret 策略序列化后的配置值
"""
old_secret = getattr(config, 'secret', '1') == '0'
if old_secret == item.secret:
return getattr(config, 'config_value', None)
current_value = cls.deserialize_config_value(
getattr(config, 'config_value', None),
getattr(config, 'config_type', 'string'),
secret=old_secret,
)
return cls.serialize_config_value(current_value, secret=item.secret)
@classmethod
def serialize_value(cls, value: PluginConfigValue) -> str | None:
"""
序列化插件配置值。
:param value: 原始配置值
:return: 字符串配置值
"""
if value is None:
return None
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
@classmethod
def serialize_config_value(cls, value: PluginConfigValue, *, secret: bool = False) -> str | None:
"""
序列化插件配置值,敏感值加密落库。
:param value: 原始配置值
:param secret: 是否敏感配置
:return: 可落库字符串
"""
serialized_value = cls.serialize_value(value)
if not secret or serialized_value in (None, ''):
return serialized_value
return f'{cls.ENCRYPTED_PREFIX}{CryptoUtil.encrypt(serialized_value)}'
@classmethod
def deserialize_value(cls, value: str | None, config_type: str = 'string') -> PluginConfigValue:
"""
反序列化插件配置值。
:param value: 字符串配置值
:param config_type: 配置值类型
:return: 反序列化后的配置值
"""
if value is None:
return None
if config_type == 'boolean':
return str(value).lower() in {'1', 'true', 'yes', 'on'}
if config_type == 'number':
try:
return int(value) if float(value).is_integer() else float(value)
except ValueError:
return value
if config_type == 'json':
try:
return cast('PluginConfigValue', json.loads(value))
except json.JSONDecodeError:
return value
return value
@classmethod
def deserialize_config_value(
cls,
value: str | None,
config_type: str = 'string',
*,
secret: bool = False,
) -> PluginConfigValue:
"""
反序列化插件配置值,敏感配置必须是平台加密格式。
:param value: 数据库存储值
:param config_type: 配置类型
:param secret: 是否敏感配置
:return: 反序列化后的配置值
"""
raw_value = value
if secret and raw_value not in (None, ''):
if not isinstance(raw_value, str) or not raw_value.startswith(cls.ENCRYPTED_PREFIX):
raise ValueError('敏感插件配置不是加密存储格式')
raw_value = CryptoUtil.decrypt(raw_value.removeprefix(cls.ENCRYPTED_PREFIX))
return cls.deserialize_value(raw_value, config_type)
@classmethod
def deserialize_options(cls, value: str | None) -> list[dict[str, PluginConfigValue]]:
"""
反序列化配置选项。
:param value: 配置选项 JSON 字符串
:return: 配置选项列表
"""
if not value:
return []
try:
options = json.loads(value)
except json.JSONDecodeError as exc:
logger.warning(f'⚠️ 插件配置选项 JSON 解析失败:{exc}')
return [{'parseError': '配置选项 JSON 解析失败'}]
if not isinstance(options, list):
logger.warning('⚠️ 插件配置选项 JSON 内容不是数组')
return [{'parseError': '配置选项 JSON 内容不是数组'}]
return cast('list[dict[str, PluginConfigValue]]', options)
@classmethod
def validate_update_value(cls, item: PluginConfigItemManifest, value: PluginConfigValue) -> None:
"""
校验配置更新值。
:param item: 插件配置项声明
:param value: 待更新配置值
:return: None
"""
if item.required and value in (None, ''):
raise ValueError(f'配置 {item.key} 不能为空')
if item.type == 'boolean' and not isinstance(value, bool):
raise ValueError(f'配置 {item.key} 必须是布尔值')
if item.type == 'number' and (not isinstance(value, int | float) or isinstance(value, bool)):
raise ValueError(f'配置 {item.key} 必须是数字')
if item.type == 'select' and item.options:
allowed_values = [option.value for option in item.options]
if value not in allowed_values:
raise ValueError(f'配置 {item.key} 不在允许的选项范围内')
if item.type == 'number':
cls._validate_number_range(item, value)
if (
item.pattern
and item.type in {'string', 'textarea', 'password'}
and value not in (None, '')
and not re.fullmatch(item.pattern, str(value))
):
raise ValueError(f'配置 {item.key} 不匹配正则约束')
@classmethod
def _validate_number_range(cls, item: PluginConfigItemManifest, value: int | float) -> None:
"""
校验数字配置值范围。
:param item: 插件配置项声明
:param value: 待更新配置值
:return: None
"""
if item.min_value is not None and value < item.min_value:
raise ValueError(f'配置 {item.key} 不能小于 {item.min_value}')
if item.max_value is not None and value > item.max_value:
raise ValueError(f'配置 {item.key} 不能大于 {item.max_value}')