Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/validation/manifest.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

716 lines
29 KiB
Python

import json
import platform
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar
from config.env import DataBaseConfig
from plugins.core.environment import PluginRuntimeEnvironmentService
from plugins.core.manifest.menu_tree import PluginMenuTree
from plugins.core.manifest.schema import PluginManifest
from plugins.core.validation.dependencies import DependencyRequirementParser
from plugins.core.validation.python_requirements import PythonRequirementParser
from plugins.core.validation.result import PluginValidationIssue
from plugins.core.validation.versioning import PluginVersionConstraintMatcher
COMPATIBILITY_CONSTRAINT_PATTERN = re.compile(r'^\s*([<>=!~^]{1,2})?\s*([A-Za-z0-9_.+\-!*]+)\s*$')
UNRESOLVED_NODE_VERSION = object()
@dataclass(frozen=True)
class PluginManifestCheckResult:
"""
插件 manifest 非阻断检查结果。
:param plugin_id: 插件 ID
:param issues: manifest 检查问题项列表
"""
plugin_id: str
issues: list[PluginValidationIssue]
@property
def ok(self) -> bool:
"""
判断 manifest 检查是否存在阻断错误。
:return: 是否不存在 error 级问题
"""
return not self.error_issues
@property
def error_issues(self) -> list[PluginValidationIssue]:
"""
获取 error 级问题项。
:return: error 级问题项列表
"""
return [issue for issue in self.issues if issue.level == 'error']
@property
def warning_issues(self) -> list[PluginValidationIssue]:
"""
获取 warning 级问题项。
:return: warning 级问题项列表
"""
return [issue for issue in self.issues if issue.level == 'warning']
class PluginManifestChecker:
"""
插件 manifest 非阻断检查器。
使用 Checker 模式承载不适合放入 Pydantic 强校验的提示类规则。
"""
_node_version_cache: ClassVar[object | str | None] = UNRESOLVED_NODE_VERSION
def __init__(
self,
*,
backend_root: Path | None = None,
frontend_root: Path | None = None,
python_version: str | None = None,
node_version: str | None = None,
) -> None:
"""
初始化插件 manifest 非阻断检查器。
:param backend_root: 后端项目根目录
:param frontend_root: 前端项目根目录
:param python_version: 当前 Python 版本
:param node_version: 当前 Node.js 版本
:return: None
"""
self.backend_root = backend_root or Path(__file__).resolve().parents[3]
self.frontend_root = frontend_root or Path(
PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_dir()
)
self.python_version = python_version or platform.python_version()
self.node_version = node_version
def check(self, manifest: PluginManifest) -> PluginManifestCheckResult:
"""
检查插件 manifest 提示类问题。
:param manifest: 插件 manifest
:return: manifest 检查结果
"""
issues = []
issues.extend(self._check_secret_config_defaults(manifest))
issues.extend(self._check_secret_config_type_alignment(manifest))
issues.extend(self._check_unpinned_dependencies(manifest))
issues.extend(self._check_resources_without_purge_hook(manifest))
issues.extend(self._check_required_config_without_default(manifest))
issues.extend(self._check_ineffective_config_constraints(manifest))
issues.extend(self._check_permissions_without_plugin_prefix(manifest))
issues.extend(self._check_frontend_menus_without_permissions(manifest))
issues.extend(self._check_button_menus_without_permission(manifest))
issues.extend(self._check_button_menu_structure(manifest))
issues.extend(self._check_permission_button_parent(manifest))
issues.extend(self._check_lifecycle_script_order(manifest))
issues.extend(self._check_enabled_jobs_without_health_checker(manifest))
issues.extend(self._check_unpaired_runtime_hooks(manifest))
issues.extend(self._check_compatibility(manifest))
return PluginManifestCheckResult(plugin_id=manifest.id, issues=issues)
def _check_secret_config_defaults(self, manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查敏感配置默认值声明。
:param manifest: 插件 manifest
:return: 敏感配置默认值问题项列表
"""
issues = []
for config_item in manifest.config.items:
if not config_item.secret or config_item.default in (None, ''):
continue
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='secret_config_default',
path=f'config.items.{config_item.key}.default',
message=f'敏感配置 {config_item.key} 声明了非空默认值',
suggestion='建议删除默认值,改为安装后在插件配置中录入',
ok=True,
)
)
return issues
@staticmethod
def _check_secret_config_type_alignment(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查敏感配置类型和 secret 标记是否一致。
:param manifest: 插件 manifest
:return: 敏感配置类型一致性问题项列表
"""
issues = []
for config_item in manifest.config.items:
if config_item.type == 'password' and not config_item.secret:
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='password_config_without_secret',
path=f'config.items.{config_item.key}.secret',
message=f'密码配置 {config_item.key} 未声明 secret=true',
suggestion='建议为 password 类型配置显式声明 secret=true,避免配置展示和导出时泄露敏感值',
ok=True,
)
)
if config_item.secret and config_item.type != 'password':
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='secret_config_non_password_type',
path=f'config.items.{config_item.key}.type',
message=f'敏感配置 {config_item.key} 的类型不是 password',
suggestion='建议将敏感配置类型设置为 password,便于前端输入控件、导出和审计统一脱敏处理',
ok=True,
)
)
return issues
def _check_unpinned_dependencies(self, manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查未声明版本约束的依赖。
:param manifest: 插件 manifest
:return: 未声明版本约束问题项列表
"""
issues = []
for dependency_kind, requirements in (
('python', manifest.dependencies.python),
('npm', manifest.dependencies.npm),
('npmDev', manifest.dependencies.npm_dev),
):
for index, requirement in enumerate(requirements):
if dependency_kind == 'python':
parsed_python = PythonRequirementParser.parse(requirement)
name = parsed_python.name
has_version_constraint = bool(parsed_python.required_version)
else:
parsed_requirement = DependencyRequirementParser.parse(requirement)
name = parsed_requirement.name
has_version_constraint = bool(parsed_requirement.required_version)
if has_version_constraint:
continue
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='dependency_unpinned',
path=f'dependencies.{dependency_kind}.{index}',
message=f'{dependency_kind} 依赖 {name} 未声明版本约束',
suggestion='建议为插件依赖声明最小版本或兼容版本范围,降低环境漂移风险',
ok=True,
)
)
for dependency in manifest.dependencies.plugins:
if dependency.version:
continue
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='plugin_dependency_unpinned',
path=f'dependencies.plugins.{dependency.id}.version',
message=f'插件依赖 {dependency.id} 未声明版本约束',
suggestion='建议声明依赖插件的最小版本或兼容版本范围',
ok=True,
)
)
return issues
@staticmethod
def _check_resources_without_purge_hook(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查资源声明是否缺少物理清理钩子。
:param manifest: 插件 manifest
:return: 资源清理提示问题项列表
"""
resources = manifest.resources
resource_count = len(resources.static) + len(resources.uploads) + len(resources.temp)
if resource_count == 0 or manifest.backend.hooks.on_purge:
return []
return [
PluginValidationIssue(
level='warning',
category='manifest',
kind='resources_without_purge_hook',
path='resources',
message='插件声明了资源清单,但未声明 onPurge 清理钩子',
suggestion='如资源需要随插件物理清理,请通过 backend.hooks.onPurge 显式实现清理逻辑',
ok=True,
)
]
@staticmethod
def _check_required_config_without_default(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查必填配置是否缺少默认值。
:param manifest: 插件 manifest
:return: 必填配置默认值提示问题项列表
"""
issues = []
for config_item in manifest.config.items:
if not config_item.required or config_item.default not in (None, ''):
continue
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='required_config_without_default',
path=f'config.items.{config_item.key}.default',
message=f'必填配置 {config_item.key} 未声明默认值',
suggestion='建议提供安全默认值,或在插件 README 中明确安装后必须配置该项',
ok=True,
)
)
return issues
@staticmethod
def _check_ineffective_config_constraints(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查配置项声明了当前类型不会生效的增强约束。
:param manifest: 插件 manifest
:return: 无效配置增强约束问题项列表
"""
issues = []
for config_item in manifest.config.items:
if config_item.type != 'number' and (
config_item.min_value is not None or config_item.max_value is not None
):
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='ineffective_config_constraint',
path=f'config.items.{config_item.key}.min/max',
message=f'配置 {config_item.key} 不是 number 类型,min/max 约束不会生效',
suggestion='仅 number 类型配置支持 min/max;请删除该约束或将配置类型改为 number',
ok=True,
)
)
if config_item.type not in {'string', 'textarea', 'password'} and config_item.pattern:
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='ineffective_config_constraint',
path=f'config.items.{config_item.key}.pattern',
message=f'配置 {config_item.key} 不是文本类型,pattern 约束不会生效',
suggestion='仅 string、textarea、password 类型配置支持 pattern;请删除该约束或调整配置类型',
ok=True,
)
)
if config_item.type != 'select' and config_item.options:
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='ineffective_config_constraint',
path=f'config.items.{config_item.key}.options',
message=f'配置 {config_item.key} 不是 select 类型,options 声明不会生效',
suggestion='仅 select 类型配置支持 options;请删除 options 或将配置类型改为 select',
ok=True,
)
)
return issues
@staticmethod
def _check_permissions_without_plugin_prefix(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查权限标识是否使用插件 ID 前缀。
:param manifest: 插件 manifest
:return: 权限前缀提示问题项列表
"""
expected_prefix = f'{manifest.id}:'
permission_set = set(manifest.permission_codes) | PluginMenuTree.collect_permissions(manifest.frontend.menus)
declared_permissions = {
permission for permission in permission_set if not permission.startswith(expected_prefix)
}
return [
PluginValidationIssue(
level='warning',
category='manifest',
kind='permission_without_plugin_prefix',
path=f'permissions.{permission}',
message=f'权限标识 {permission} 未使用插件 ID 前缀',
suggestion=f'建议使用 {expected_prefix}<resource>:<action> 格式,降低与平台或其他插件权限冲突的风险',
ok=True,
)
for permission in sorted(declared_permissions)
]
@staticmethod
def _check_frontend_menus_without_permissions(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查前端菜单是否完全缺少权限声明。
:param manifest: 插件 manifest
:return: 前端菜单权限提示问题项列表
"""
if not manifest.frontend.menus:
return []
if not any(menu.type != 'F' for menu in PluginMenuTree.flatten(manifest.frontend.menus)):
return []
if manifest.permission_codes or PluginMenuTree.collect_permissions(manifest.frontend.menus):
return []
return [
PluginValidationIssue(
level='warning',
category='manifest',
kind='frontend_menus_without_permissions',
path='frontend.menus',
message='插件声明了前端菜单,但没有声明任何权限标识',
suggestion='建议至少为页面菜单声明 perms 并在顶层 permissions 中同步声明,便于角色授权和菜单可见性统一管理',
ok=True,
)
]
@staticmethod
def _check_button_menus_without_permission(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查按钮菜单是否声明权限标识。
:param manifest: 插件 manifest
:return: 按钮菜单权限提示问题项列表
"""
issues = []
for menu in PluginMenuTree.flatten(manifest.frontend.menus):
if menu.type != 'F' or menu.perms:
continue
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='button_menu_without_permission',
path=f'frontend.menus.{menu.path}.perms',
message=f'按钮菜单 {menu.name} 未声明权限标识',
suggestion='建议为按钮菜单声明 perms,并在顶层 permissions 中同步声明,便于前端权限指令和后端权限校验统一控制',
ok=True,
)
)
return issues
@staticmethod
def _check_button_menu_structure(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查按钮菜单结构是否混入路由菜单语义。
:param manifest: 插件 manifest
:return: 按钮菜单结构提示问题项列表
"""
issues = []
for menu in PluginMenuTree.flatten(manifest.frontend.menus):
if menu.type != 'F':
continue
if menu.component:
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='button_menu_with_component',
path=f'frontend.menus.{menu.path}.component',
message=f'按钮菜单 {menu.name} 声明了 component',
suggestion='按钮菜单只用于权限动作,不参与路由渲染;建议将 component 留空',
ok=True,
)
)
if menu.children:
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='button_menu_with_children',
path=f'frontend.menus.{menu.path}.children',
message=f'按钮菜单 {menu.name} 声明了子菜单',
suggestion='按钮菜单不应承载子菜单;建议将子菜单移动到目录或页面菜单下',
ok=True,
)
)
return issues
@staticmethod
def _check_permission_button_parent(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查自动权限按钮是否存在可挂载的页面或目录菜单。
:param manifest: 插件 manifest
:return: 自动权限按钮父菜单提示问题项列表
"""
menu_permissions = PluginMenuTree.collect_permissions(manifest.frontend.menus)
auto_button_permissions = sorted(set(manifest.permission_codes) - menu_permissions)
if not auto_button_permissions:
return []
has_parent_menu = any(menu.type != 'F' for menu in PluginMenuTree.flatten(manifest.frontend.menus))
if has_parent_menu:
return []
return [
PluginValidationIssue(
level='warning',
category='manifest',
kind='permission_without_menu_parent',
path=f'permissions.{permission}',
message=f'权限标识 {permission} 未声明菜单承载,且插件没有可挂载按钮的页面或目录菜单',
suggestion='如该权限需要分配给角色,建议声明页面/目录菜单,或显式声明按钮菜单承载该权限',
ok=True,
)
for permission in auto_button_permissions
]
@staticmethod
def _check_lifecycle_script_order(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查 migration 和 seed 声明顺序是否稳定。
:param manifest: 插件 manifest
:return: 生命周期脚本顺序提示问题项列表
"""
issues = []
for field_name, script_paths in (
('migrations', manifest.backend.migrations),
('seeds', manifest.backend.seeds),
):
sorted_paths = sorted(script_paths)
if script_paths == sorted_paths:
continue
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='script_order_unsorted',
path=f'backend.{field_name}',
message=f'backend.{field_name} 未按文件名顺序声明',
suggestion='插件 migration 和 seed 会按 manifest 列表顺序执行;建议按文件名升序声明,降低执行顺序出错风险',
ok=True,
)
)
return issues
@staticmethod
def _check_enabled_jobs_without_health_checker(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查默认启用任务是否缺少健康检查声明。
:param manifest: 插件 manifest
:return: 定时任务健康检查提示问题项列表
"""
enabled_jobs = [job for job in manifest.backend.jobs if job.enabled]
if not enabled_jobs or manifest.backend.health.checker:
return []
return [
PluginValidationIssue(
level='warning',
category='manifest',
kind='enabled_jobs_without_health_checker',
path='backend.health.checker',
message='插件声明了默认启用的定时任务,但未声明健康检查 callable',
suggestion='建议为包含默认启用任务的插件声明 backend.health.checker,便于运维侧确认任务依赖状态',
ok=True,
)
]
@staticmethod
def _check_unpaired_runtime_hooks(manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查启动和关闭钩子是否成对声明。
:param manifest: 插件 manifest
:return: 生命周期钩子配对提示问题项列表
"""
hooks = manifest.backend.hooks
if bool(hooks.on_startup) == bool(hooks.on_shutdown):
return []
missing_hook = 'onShutdown' if hooks.on_startup else 'onStartup'
declared_hook = 'onStartup' if hooks.on_startup else 'onShutdown'
return [
PluginValidationIssue(
level='warning',
category='manifest',
kind='unpaired_runtime_hook',
path=f'backend.hooks.{missing_hook}',
message=f'插件声明了 {declared_hook},但未声明 {missing_hook}',
suggestion='建议启动和关闭钩子成对声明,确保运行时资源可以完整初始化和释放',
ok=True,
)
]
def _check_compatibility(self, manifest: PluginManifest) -> list[PluginValidationIssue]:
"""
检查插件平台兼容性声明。
:param manifest: 插件 manifest
:return: 平台兼容性问题项列表
"""
compatibility = manifest.compatibility
checks = [
('backendVersion', compatibility.backend_version, self._read_backend_version, 'backend'),
('frontendVersion', compatibility.frontend_version, self._read_frontend_version, 'frontend'),
('pythonVersion', compatibility.python_version, lambda: self.python_version, 'python'),
('nodeVersion', compatibility.node_version, self._resolve_node_version, 'node'),
]
issues = []
for field_name, constraint, version_loader, target_name in checks:
if not constraint:
continue
current_version = version_loader()
if current_version is None:
issues.append(
PluginValidationIssue(
level='warning',
category='manifest',
kind='compatibility_unknown',
path=f'compatibility.{field_name}',
message=f'无法读取当前 {target_name} 版本,跳过兼容性判断',
suggestion='确认运行环境可读取版本信息后重新执行插件检查',
ok=True,
)
)
continue
if self._version_satisfied(current_version, constraint):
continue
issues.append(
PluginValidationIssue(
level='error',
category='manifest',
kind='compatibility_unsatisfied',
path=f'compatibility.{field_name}',
message=f'{target_name} 版本不满足插件兼容性声明:current={current_version} required={constraint}',
suggestion='升级平台运行环境或调整插件 compatibility 声明',
ok=False,
)
)
current_database = DataBaseConfig.db_type
if compatibility.databases and current_database not in compatibility.databases:
issues.append(
PluginValidationIssue(
level='error',
category='manifest',
kind='compatibility_unsatisfied',
path='compatibility.databases',
message=(
'database 类型不满足插件兼容性声明:'
f'current={current_database} required={", ".join(compatibility.databases)}'
),
suggestion='切换到插件支持的数据库,或调整插件 compatibility.databases 声明',
ok=False,
)
)
return issues
def _read_backend_version(self) -> str | None:
"""
读取后端项目版本。
:return: 后端项目版本,读取失败时返回 None
"""
return self._read_pyproject_version(self.backend_root / 'pyproject.toml')
def _read_frontend_version(self) -> str | None:
"""
读取前端项目版本。
:return: 前端项目版本,读取失败时返回 None
"""
package_json = self.frontend_root / 'package.json'
try:
payload = json.loads(package_json.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
version = payload.get('version') if isinstance(payload, dict) else None
return str(version) if version else None
@staticmethod
def _read_pyproject_version(pyproject_path: Path) -> str | None:
"""
读取 pyproject.toml 中的项目版本。
:param pyproject_path: pyproject.toml 路径
:return: 项目版本,读取失败时返回 None
"""
try:
for line in pyproject_path.read_text(encoding='utf-8').splitlines():
stripped_line = line.strip()
if stripped_line.startswith('version ='):
return stripped_line.split('=', maxsplit=1)[1].strip().strip('"\'')
except OSError:
return None
return None
def _resolve_node_version(self) -> str | None:
"""
解析当前 Node.js 版本。
:return: Node.js 版本,读取失败时返回 None
"""
if self.node_version is not None:
return self.node_version
cached_node_version = self.__class__._node_version_cache
if cached_node_version is not UNRESOLVED_NODE_VERSION:
return cached_node_version if isinstance(cached_node_version, str) else None
try:
completed = subprocess.run(
['node', '--version'],
capture_output=True,
text=True,
check=False,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
self.__class__._node_version_cache = None
return None
if completed.returncode != 0:
self.__class__._node_version_cache = None
return None
resolved_node_version = completed.stdout.strip().lstrip('v') or None
self.__class__._node_version_cache = resolved_node_version
return resolved_node_version
@staticmethod
def _version_satisfied(current_version: str, constraint: str) -> bool:
"""
判断当前版本是否满足兼容性约束。
:param current_version: 当前版本
:param constraint: 兼容性约束
:return: 是否满足
"""
matched_constraint = COMPATIBILITY_CONSTRAINT_PATTERN.match(constraint)
if not matched_constraint:
return True
operator, required_version = matched_constraint.groups()
operator = operator or '=='
return PluginVersionConstraintMatcher.is_satisfied(current_version, operator, required_version)