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

686 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ast
from dataclasses import dataclass
from pathlib import Path
from plugins.core.discovery.scanner import DiscoveredPlugin
from plugins.core.environment import PluginRuntimeEnvironmentService
from plugins.core.manifest.menu_tree import PluginMenuTree
from plugins.core.manifest.schema import PluginJobManifest, PluginManifest, PluginMenuManifest
from plugins.core.validation.result import PluginValidationIssue, PluginValidationLevelResolver, ValidationLevel
from utils.cron_util import CronUtil
MAX_PLUGIN_JOB_NAME_LENGTH = 64
SUPPORTED_SEED_SUFFIXES = {'.py', '.sql'}
SUPPORTED_MIGRATION_SUFFIXES = {'.py', '.sql'}
ROUTER_FACTORY_NAMES = {'APIRouter', 'APIRouterPro'}
@dataclass(frozen=True)
class PluginStructureCheckItem:
"""
插件结构检查项。
"""
kind: str
path: str
ok: bool
message: str
suggestion: str = ''
@property
def level(self) -> ValidationLevel:
"""
获取结构检查项等级。
:return: 检查等级
"""
return PluginValidationLevelResolver.from_ok(self.ok)
def to_issue(self) -> PluginValidationIssue:
"""
转换为统一校验问题项。
:return: 统一校验问题项
"""
return PluginValidationIssue(
level=self.level,
category='structure',
kind=self.kind,
path=self.path,
message=self.message,
suggestion=self.suggestion,
ok=self.ok,
)
@dataclass(frozen=True)
class PluginStructureCheckResult:
"""
插件结构检查结果。
"""
plugin_id: str
items: list[PluginStructureCheckItem]
@property
def ok(self) -> bool:
"""
判断结构检查是否整体通过。
:return: 是否通过
"""
return all(item.ok for item in self.items)
@property
def failed_items(self) -> list[PluginStructureCheckItem]:
"""
获取失败检查项列表。
:return: 失败检查项列表
"""
return [item for item in self.items if not item.ok]
@property
def issues(self) -> list[PluginValidationIssue]:
"""
获取统一校验问题项列表。
:return: 统一校验问题项列表
"""
return [item.to_issue() for item in self.items]
class PluginStructureChecker:
"""
插件结构检查器。
使用 Checker 模式集中验证 manifest 引用的后端目录、seed 文件和前端页面是否存在。
"""
def __init__(self, backend_root: Path | str, frontend_root: Path | str | None = None) -> None:
"""
初始化插件结构检查器。
:param backend_root: 后端项目根目录
:param frontend_root: 前端插件根目录,默认使用运行时环境解析出的 plugins 目录
"""
self.backend_root = Path(backend_root)
self.frontend_root = (
Path(frontend_root)
if frontend_root
else Path(PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_plugins_dir())
)
def check(self, discovered_plugin: DiscoveredPlugin) -> PluginStructureCheckResult:
"""
检查插件结构。
:param discovered_plugin: 已发现插件对象
:return: 插件结构检查结果
"""
manifest = discovered_plugin.manifest
items = [
self._check_dir('backend_root', discovered_plugin.backend_path),
self._check_file('manifest', discovered_plugin.manifest_path),
]
if manifest.backend.routers.auto_scan:
controller_dir = discovered_plugin.backend_path / 'controller'
items.append(self._check_dir('controller_dir', controller_dir))
items.extend(self._check_controller_route_prefixes(discovered_plugin, controller_dir))
entity_dir = discovered_plugin.backend_path / 'entity' / 'do'
if entity_dir.exists():
items.append(self._check_dir('entity_do_dir', entity_dir))
items.extend(self._check_migration_files(discovered_plugin))
items.extend(self._check_seed_files(discovered_plugin))
items.extend(self._check_hooks(discovered_plugin))
items.extend(self._check_jobs(discovered_plugin))
items.extend(self._check_frontend(discovered_plugin))
return PluginStructureCheckResult(plugin_id=manifest.id, items=items)
def _check_controller_route_prefixes(
self,
discovered_plugin: DiscoveredPlugin,
controller_dir: Path,
) -> list[PluginStructureCheckItem]:
"""
检查插件 controller 路由前缀是否位于插件命名空间内。
:param discovered_plugin: 已发现插件对象
:param controller_dir: controller 目录
:return: 路由前缀检查项列表
"""
if not controller_dir.is_dir():
return []
items = []
for controller_file in sorted(controller_dir.glob('[!_]*.py')):
items.extend(self.check_controller_file_route_prefixes(discovered_plugin.manifest.id, controller_file))
return items
def check_controller_file_route_prefixes(
self,
plugin_id: str,
controller_file: Path,
) -> list[PluginStructureCheckItem]:
"""
检查单个 controller 文件内的路由前缀。
:param plugin_id: 插件ID
:param controller_file: controller 文件
:return: 路由前缀检查项列表
"""
try:
module_ast = ast.parse(controller_file.read_text(encoding='utf-8'), filename=str(controller_file))
except SyntaxError as exc:
return [
PluginStructureCheckItem(
kind='controller_route_prefix',
path=str(controller_file),
ok=False,
message=f'controller 文件语法错误,无法检查路由前缀:{exc}',
)
]
items = []
for node in ast.walk(module_ast):
if not isinstance(node, ast.Call) or not self._is_router_factory_call(node):
continue
prefix = self._get_router_prefix_literal(node)
path = f'{controller_file}:{getattr(node, "lineno", 1)}'
ok = prefix is not None and self._is_plugin_route_prefix(plugin_id, prefix)
items.append(
PluginStructureCheckItem(
kind='controller_route_prefix',
path=path,
ok=ok,
message=(
f'controller 路由前缀位于插件命名空间内:{prefix}'
if ok
else f'controller 路由前缀必须位于插件 {plugin_id} 命名空间内:{prefix or "<未声明>"}'
),
suggestion=f'请使用 /{plugin_id} 或 /plugin/{plugin_id} 作为路由前缀根路径',
)
)
return items
@staticmethod
def _is_router_factory_call(node: ast.Call) -> bool:
"""
判断 AST 调用是否为 APIRouter/APIRouterPro 构造。
:param node: AST 调用节点
:return: 是否为路由构造调用
"""
if isinstance(node.func, ast.Name):
return node.func.id in ROUTER_FACTORY_NAMES
if isinstance(node.func, ast.Attribute):
return node.func.attr in ROUTER_FACTORY_NAMES
return False
@staticmethod
def _get_router_prefix_literal(node: ast.Call) -> str | None:
"""
提取路由构造调用中的 prefix 字符串字面量。
:param node: AST 调用节点
:return: prefix 字符串,未声明或非字面量时返回 None
"""
for keyword in node.keywords:
if (
keyword.arg == 'prefix'
and isinstance(keyword.value, ast.Constant)
and isinstance(keyword.value.value, str)
):
return keyword.value.value
return None
@staticmethod
def _is_plugin_route_prefix(plugin_id: str, prefix: str) -> bool:
"""
判断路由前缀是否属于插件命名空间。
:param plugin_id: 插件ID
:param prefix: 路由前缀
:return: 是否属于插件命名空间
"""
plugin_prefix = f'/{plugin_id}'
nested_plugin_prefix = f'/plugin/{plugin_id}'
return prefix in (plugin_prefix, nested_plugin_prefix) or prefix.startswith(
(f'{plugin_prefix}/', f'{nested_plugin_prefix}/')
)
def _check_hooks(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]:
"""
检查插件生命周期钩子声明。
:param discovered_plugin: 已发现插件对象
:return: 生命周期钩子检查项列表
"""
items = []
hooks = discovered_plugin.manifest.backend.hooks
hook_mapping = {
'on_install': hooks.on_install,
'on_upgrade': hooks.on_upgrade,
'on_startup': hooks.on_startup,
'on_shutdown': hooks.on_shutdown,
'on_purge': hooks.on_purge,
}
for hook_name, hook_path in hook_mapping.items():
if not hook_path:
continue
items.append(self._check_hook_boundary(discovered_plugin.manifest.backend.module, hook_name, hook_path))
items.append(self._check_hook_callable_importable(discovered_plugin, hook_name, hook_path))
return items
def _check_hook_callable_importable(
self,
discovered_plugin: DiscoveredPlugin,
hook_name: str,
hook_path: str,
) -> PluginStructureCheckItem:
"""
检查生命周期钩子 callable 是否可以导入。
:param discovered_plugin: 已发现插件对象
:param hook_name: 生命周期钩子名称
:param hook_path: 生命周期钩子声明
:return: 结构检查项
"""
try:
module_path, callable_name = hook_path.split(':', maxsplit=1)
module_name = self._resolve_hook_module_name(discovered_plugin.manifest.backend.module, module_path)
module_file = self._resolve_plugin_module_file(discovered_plugin, module_name)
ok = self._module_file_declares_async_callable(module_file, callable_name)
except Exception as exc:
return PluginStructureCheckItem(
kind='hook_callable',
path=f'{hook_name}:{hook_path}',
ok=False,
message=f'生命周期钩子不可静态解析:{exc}',
)
return PluginStructureCheckItem(
kind='hook_callable',
path=f'{hook_name}:{hook_path}',
ok=ok,
message=(
f'生命周期钩子是异步函数:{hook_path}'
if ok
else f'生命周期钩子必须是使用 async def 声明的顶层函数:{hook_path}'
),
)
@staticmethod
def _check_hook_boundary(backend_module: str, hook_name: str, hook_path: str) -> PluginStructureCheckItem:
"""
检查生命周期钩子是否位于当前插件后端模块内。
:param backend_module: 插件后端模块路径
:param hook_name: 生命周期钩子名称
:param hook_path: 生命周期钩子声明
:return: 结构检查项
"""
module_path = hook_path.split(':', maxsplit=1)[0]
resolved_module_path = PluginStructureChecker._resolve_hook_module_name(backend_module, module_path)
ok = resolved_module_path == backend_module or resolved_module_path.startswith(f'{backend_module}.')
return PluginStructureCheckItem(
kind='hook_boundary',
path=f'{hook_name}:{hook_path}',
ok=ok,
message=f'生命周期钩子位于插件模块内:{hook_path}' if ok else f'生命周期钩子越界:{hook_path}',
)
@staticmethod
def _resolve_hook_module_name(backend_module: str, module_path: str) -> str:
"""
解析生命周期钩子模块名。
:param backend_module: 插件后端模块路径
:param module_path: manifest 中声明的钩子模块路径
:return: 完整 Python 模块名
"""
if module_path == backend_module or module_path.startswith(f'{backend_module}.'):
return module_path
if module_path.startswith('plugins.'):
return module_path
return f'{backend_module}.{module_path}'
def _check_jobs(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]:
"""
检查插件定时任务声明。
:param discovered_plugin: 已发现插件对象
:return: 定时任务检查项列表
"""
items = []
manifest = discovered_plugin.manifest
for job in manifest.backend.jobs:
items.append(self._check_job_name_length(manifest.id, job))
items.append(self._check_job_callable_boundary(manifest.backend.module, job))
items.append(self._check_job_callable_importable(job))
items.append(self._check_job_cron_expression(job))
return items
@staticmethod
def _check_job_name_length(plugin_id: str, job: PluginJobManifest) -> PluginStructureCheckItem:
"""
检查插件任务映射到系统任务表后的名称长度。
:param plugin_id: 插件ID
:param job: 插件定时任务声明
:return: 结构检查项
"""
job_name = f'{plugin_id}:{job.id}'
ok = len(job_name) <= MAX_PLUGIN_JOB_NAME_LENGTH
return PluginStructureCheckItem(
kind='job_name',
path=job_name,
ok=ok,
message=f'任务名称长度有效:{job_name}' if ok else f'任务名称超过 64 字符:{job_name}',
)
@staticmethod
def _check_job_callable_boundary(
backend_module: str,
job: PluginJobManifest,
) -> PluginStructureCheckItem:
"""
检查任务 callable 是否位于当前插件后端模块内。
:param backend_module: 插件后端模块路径
:param job: 插件定时任务声明
:return: 结构检查项
"""
ok = job.callable.startswith(f'{backend_module}.')
return PluginStructureCheckItem(
kind='job_callable_boundary',
path=job.callable,
ok=ok,
message=f'任务 callable 位于插件模块内:{job.callable}' if ok else f'任务 callable 越界:{job.callable}',
)
def _check_job_callable_importable(self, job: PluginJobManifest) -> PluginStructureCheckItem:
"""
检查任务 callable 是否可通过源码静态解析。
:param job: 插件定时任务声明
:return: 结构检查项
"""
try:
module_path, callable_name = job.callable.rsplit('.', 1)
module_file = self._resolve_backend_module_file(module_path)
ok = self._module_file_declares_callable(module_file, callable_name)
except Exception as exc:
return PluginStructureCheckItem(
kind='job_callable',
path=job.callable,
ok=False,
message=f'任务 callable 不可静态解析:{exc}',
)
return PluginStructureCheckItem(
kind='job_callable',
path=job.callable,
ok=ok,
message=f'任务 callable 可调用:{job.callable}' if ok else f'任务 callable 不是可调用对象:{job.callable}',
)
def _resolve_backend_module_file(self, module_path: str) -> Path:
"""
解析后端模块对应的 Python 文件。
:param module_path: Python 模块路径
:return: 模块文件路径
"""
module_file = self.backend_root / Path(*module_path.split('.')).with_suffix('.py')
if module_file.is_file():
return module_file
package_init = self.backend_root / Path(*module_path.split('.')) / '__init__.py'
if package_init.is_file():
return package_init
raise ImportError(f'无法找到模块文件:{module_path}')
def _resolve_plugin_module_file(self, discovered_plugin: DiscoveredPlugin, module_path: str) -> Path:
"""
解析插件模块对应的 Python 文件,不导入执行模块。
:param discovered_plugin: 已发现插件对象
:param module_path: Python 模块路径
:return: 模块文件路径
"""
backend_module = discovered_plugin.manifest.backend.module
if module_path == backend_module:
module_file = discovered_plugin.backend_path / '__init__.py'
elif module_path.startswith(f'{backend_module}.'):
relative_module = module_path.removeprefix(backend_module).lstrip('.')
module_file = discovered_plugin.backend_path.joinpath(*relative_module.split('.')).with_suffix('.py')
else:
module_file = self.backend_root / Path(*module_path.split('.')).with_suffix('.py')
if module_file.is_file():
return module_file
raise ImportError(f'无法找到模块文件:{module_path}')
@staticmethod
def _module_file_declares_callable(module_file: Path, callable_name: str) -> bool:
"""
通过 AST 判断模块是否声明了顶层可调用符号。
:param module_file: 模块文件路径
:param callable_name: callable 名称
:return: 是否声明了可调用符号
"""
module_ast = ast.parse(module_file.read_text(encoding='utf-8'), filename=str(module_file))
return any(
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) and node.name == callable_name
for node in module_ast.body
)
@staticmethod
def _module_file_declares_async_callable(module_file: Path, callable_name: str) -> bool:
"""
通过 AST 判断模块是否声明了顶层异步函数。
:param module_file: 模块文件路径
:param callable_name: callable 名称
:return: 是否声明了异步函数
"""
module_ast = ast.parse(module_file.read_text(encoding='utf-8'), filename=str(module_file))
return any(isinstance(node, ast.AsyncFunctionDef) and node.name == callable_name for node in module_ast.body)
@staticmethod
def _check_job_cron_expression(job: PluginJobManifest) -> PluginStructureCheckItem:
"""
检查任务 cron 表达式。
:param job: 插件定时任务声明
:return: 结构检查项
"""
ok = CronUtil.validate_cron_expression(job.cron_expression)
return PluginStructureCheckItem(
kind='job_cron',
path=f'{job.id}:{job.cron_expression}',
ok=ok,
message=(
f'任务 cron 表达式有效:{job.cron_expression}' if ok else f'任务 cron 表达式无效:{job.cron_expression}'
),
)
def _check_migration_files(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]:
"""
检查 migration 文件。
:param discovered_plugin: 已发现插件对象
:return: migration 检查项列表
"""
items = []
for migration_path in discovered_plugin.manifest.backend.migrations:
items.append(
self._check_plugin_relative_file('migration_file', discovered_plugin.backend_path, migration_path)
)
items.append(self._check_migration_type(migration_path))
return items
@staticmethod
def _check_migration_type(migration_path: str) -> PluginStructureCheckItem:
"""
检查 migration 文件类型。
:param migration_path: migration 相对插件根目录路径
:return: 结构检查项
"""
ok = Path(migration_path).suffix in SUPPORTED_MIGRATION_SUFFIXES
return PluginStructureCheckItem(
kind='migration_type',
path=migration_path,
ok=ok,
message=f'支持的 migration:{migration_path}' if ok else f'暂不支持的 migration 类型:{migration_path}',
)
def _check_seed_files(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]:
"""
检查 seed 文件。
:param discovered_plugin: 已发现插件对象
:return: seed 检查项列表
"""
items = []
for seed_path in discovered_plugin.manifest.backend.seeds:
items.append(self._check_plugin_relative_file('seed_file', discovered_plugin.backend_path, seed_path))
items.append(self._check_seed_type(seed_path))
return items
@staticmethod
def _check_seed_type(seed_path: str) -> PluginStructureCheckItem:
"""
检查 seed 文件类型。
:param seed_path: seed 相对插件根目录路径
:return: 结构检查项
"""
ok = Path(seed_path).suffix in SUPPORTED_SEED_SUFFIXES
return PluginStructureCheckItem(
kind='seed_type',
path=seed_path,
ok=ok,
message=f'支持的 seed:{seed_path}' if ok else f'暂不支持的 seed 类型:{seed_path}',
)
def _check_frontend(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]:
"""
检查前端插件目录和菜单组件。
:param discovered_plugin: 已发现插件对象
:return: 前端检查项列表
"""
manifest = discovered_plugin.manifest
plugin_view_menus = [
menu
for menu in PluginMenuTree.flatten(manifest.frontend.menus)
if PluginMenuTree.is_plugin_component(menu.component)
]
frontend_plugin_root = self.frontend_root / (manifest.frontend.plugin_id or manifest.id)
if not plugin_view_menus:
return []
items = [self._check_dir('frontend_root', frontend_plugin_root)]
items.append(self._check_dir('frontend_views_path', frontend_plugin_root / manifest.frontend.views_path))
items.append(self._check_dir('frontend_api_path', frontend_plugin_root / manifest.frontend.api_path))
items.extend(self._check_plugin_view(frontend_plugin_root, manifest, menu) for menu in plugin_view_menus)
return items
def _check_plugin_view(
self,
frontend_plugin_root: Path,
manifest: PluginManifest,
menu: PluginMenuManifest,
) -> PluginStructureCheckItem:
"""
检查插件菜单组件对应的 Vue 页面。
:param frontend_plugin_root: 前端插件根目录
:param manifest: 插件清单
:param menu: 菜单声明
:return: 页面检查项
"""
view_path = PluginMenuTree.resolve_plugin_view_path(manifest, menu.component)
if view_path is None:
return PluginStructureCheckItem(
kind='frontend_view',
path=menu.component,
ok=False,
message=f'插件组件路径格式错误:{menu.component}',
)
return self._check_file('frontend_view', frontend_plugin_root / view_path)
@staticmethod
def _check_dir(kind: str, path: Path) -> PluginStructureCheckItem:
"""
检查目录是否存在。
:param kind: 检查类型
:param path: 目录路径
:return: 结构检查项
"""
return PluginStructureCheckItem(
kind=kind,
path=str(path),
ok=path.is_dir(),
message=f'目录存在:{path}' if path.is_dir() else f'目录不存在:{path}',
)
@staticmethod
def _check_file(kind: str, path: Path) -> PluginStructureCheckItem:
"""
检查文件是否存在。
:param kind: 检查类型
:param path: 文件路径
:return: 结构检查项
"""
return PluginStructureCheckItem(
kind=kind,
path=str(path),
ok=path.is_file(),
message=f'文件存在:{path}' if path.is_file() else f'文件不存在:{path}',
)
@classmethod
def _check_plugin_relative_file(cls, kind: str, plugin_root: Path, relative_path: str) -> PluginStructureCheckItem:
"""
检查插件相对路径文件是否存在且未越过插件根目录。
:param kind: 检查类型
:param plugin_root: 插件后端根目录
:param relative_path: 相对插件根目录路径
:return: 结构检查项
"""
file_path = (plugin_root / relative_path).resolve()
resolved_plugin_root = plugin_root.resolve()
if resolved_plugin_root not in file_path.parents:
return PluginStructureCheckItem(
kind=kind,
path=relative_path,
ok=False,
message=f'文件路径不能越过插件根目录:{relative_path}',
suggestion='请使用插件目录内的相对路径',
)
return cls._check_file(kind, file_path)