mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-06 09:41:55 +00:00
* 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: 更新后端依赖文件
266 lines
9.5 KiB
Python
266 lines
9.5 KiB
Python
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import yaml
|
||
from pydantic import ValidationError
|
||
|
||
from plugins.core.manifest.schema import PluginManifest, PluginManifestError, PluginManifestFactory
|
||
|
||
PLUGIN_MANIFEST_NAME = 'plugin.yaml'
|
||
UNSUPPORTED_PLUGIN_MANIFEST_NAMES = ('plugin.yml',)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DiscoveredPlugin:
|
||
"""
|
||
已发现插件。
|
||
"""
|
||
|
||
manifest: PluginManifest
|
||
backend_path: Path
|
||
manifest_path: Path
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PluginDiscoveryError:
|
||
"""
|
||
插件发现错误。
|
||
|
||
用于在容错扫描模式下记录单个插件加载失败,便于上层隔离损坏插件。
|
||
"""
|
||
|
||
manifest_path: Path | None
|
||
plugin_dir: Path
|
||
error_message: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PluginDiscoveryResult:
|
||
"""
|
||
插件发现结果。
|
||
|
||
同时承载成功发现的插件和加载失败明细,避免单个错误插件拖垮整体扫描。
|
||
"""
|
||
|
||
plugins: list[DiscoveredPlugin] = field(default_factory=list)
|
||
errors: list[PluginDiscoveryError] = field(default_factory=list)
|
||
|
||
@property
|
||
def has_errors(self) -> bool:
|
||
"""
|
||
判断是否存在发现错误。
|
||
|
||
:return: 是否存在错误
|
||
"""
|
||
return bool(self.errors)
|
||
|
||
|
||
class PluginScanner:
|
||
"""
|
||
插件扫描器。
|
||
|
||
提供两种扫描语义:
|
||
|
||
- :meth:`discover`:严格扫描,任一插件清单错误立即抛出 ``PluginManifestError``。
|
||
- :meth:`discover_with_errors`:容错扫描,逐插件隔离失败,plugin.yml 文件名错误、
|
||
YAML 损坏、清单校验失败等都作为对应插件的错误记录,不影响其他插件。
|
||
"""
|
||
|
||
def __init__(self, plugins_root: Path | str) -> None:
|
||
"""
|
||
初始化插件扫描器。
|
||
|
||
:param plugins_root: 后端插件根目录
|
||
"""
|
||
self.plugins_root = Path(plugins_root)
|
||
|
||
def discover(self) -> list[DiscoveredPlugin]:
|
||
"""
|
||
严格扫描插件目录并返回清单校验通过的插件。
|
||
|
||
任一插件清单错误(包括 plugin.yml 文件名、YAML 损坏、清单校验失败)都会立即
|
||
抛出 ``PluginManifestError``,保持与历史行为兼容。需要逐插件隔离时请使用
|
||
:meth:`discover_with_errors`。
|
||
|
||
:return: 已发现插件列表
|
||
:raises PluginManifestError: 任一插件清单错误
|
||
"""
|
||
if not self.plugins_root.exists():
|
||
return []
|
||
self._ensure_plugins_root_is_dir()
|
||
self._reject_unsupported_manifest_names_strict()
|
||
|
||
return [
|
||
self.load_manifest(manifest_path)
|
||
for manifest_path in sorted(self.plugins_root.glob(f'*/{PLUGIN_MANIFEST_NAME}'))
|
||
]
|
||
|
||
def discover_with_errors(self) -> PluginDiscoveryResult:
|
||
"""
|
||
容错扫描插件目录,返回成功发现的插件及失败明细。
|
||
|
||
单个插件的任何错误(plugin.yml 文件名、YAML 损坏、清单校验失败、目录名不一致等)
|
||
都只隔离失败插件,不影响其他正常插件。根目录配置类错误(目录不存在、根路径不是
|
||
目录)仍以异常形式抛出。
|
||
|
||
:return: 插件发现结果
|
||
:raises PluginManifestError: 根路径不是目录
|
||
"""
|
||
if not self.plugins_root.exists():
|
||
return PluginDiscoveryResult()
|
||
self._ensure_plugins_root_is_dir()
|
||
|
||
result = PluginDiscoveryResult()
|
||
# 先收集 plugin.yml 文件名错误,按插件目录隔离
|
||
unsupported_name_errors = self._collect_unsupported_manifest_name_errors()
|
||
result.errors.extend(unsupported_name_errors)
|
||
invalid_plugin_dirs = {error.plugin_dir for error in unsupported_name_errors}
|
||
# 再逐插件加载 plugin.yaml
|
||
for manifest_path in sorted(self.plugins_root.glob(f'*/{PLUGIN_MANIFEST_NAME}')):
|
||
if manifest_path.parent in invalid_plugin_dirs:
|
||
continue
|
||
try:
|
||
result.plugins.append(self.load_manifest(manifest_path))
|
||
except Exception as exc:
|
||
result.errors.append(
|
||
PluginDiscoveryError(
|
||
manifest_path=manifest_path,
|
||
plugin_dir=manifest_path.parent,
|
||
error_message=str(exc),
|
||
)
|
||
)
|
||
return result
|
||
|
||
def load_manifest(self, manifest_path: Path | str) -> DiscoveredPlugin:
|
||
"""
|
||
加载单个插件清单。
|
||
|
||
:param manifest_path: 插件清单路径
|
||
:return: 已发现插件
|
||
:raises PluginManifestError: 清单不存在、不是文件、YAML 解析失败或清单校验失败
|
||
"""
|
||
current_manifest_path = Path(manifest_path)
|
||
if not current_manifest_path.exists():
|
||
raise PluginManifestError(f'插件清单不存在:{current_manifest_path}')
|
||
if not current_manifest_path.is_file():
|
||
raise PluginManifestError(f'插件清单不是文件:{current_manifest_path}')
|
||
|
||
raw_manifest = self._read_yaml(current_manifest_path)
|
||
try:
|
||
manifest = PluginManifestFactory.create(raw_manifest)
|
||
except ValidationError as exc:
|
||
error_summary = self._format_validation_errors(exc)
|
||
raise PluginManifestError(f'插件清单校验失败:{current_manifest_path},{error_summary}') from exc
|
||
|
||
backend_path = current_manifest_path.parent
|
||
if backend_path.name != manifest.id:
|
||
raise PluginManifestError(f'插件目录名必须与插件 id 一致:目录={backend_path.name},id={manifest.id}')
|
||
|
||
return DiscoveredPlugin(manifest=manifest, backend_path=backend_path, manifest_path=current_manifest_path)
|
||
|
||
@staticmethod
|
||
def _read_yaml(manifest_path: Path) -> dict[str, Any]:
|
||
"""
|
||
读取 YAML 清单文件。
|
||
|
||
:param manifest_path: 插件清单路径
|
||
:return: YAML 解析后的字典
|
||
:raises PluginManifestError: YAML 解析失败或内容不是对象
|
||
"""
|
||
try:
|
||
with manifest_path.open('r', encoding='utf-8') as manifest_file:
|
||
data = yaml.safe_load(manifest_file) or {}
|
||
except yaml.YAMLError as exc:
|
||
raise PluginManifestError(f'插件清单 YAML 解析失败:{manifest_path}') from exc
|
||
|
||
if not isinstance(data, dict):
|
||
raise PluginManifestError(f'插件清单必须是 YAML 对象:{manifest_path}')
|
||
return data
|
||
|
||
@staticmethod
|
||
def _format_validation_errors(exc: ValidationError, *, limit: int = 5) -> str:
|
||
"""
|
||
格式化 Pydantic 字段校验错误摘要。
|
||
|
||
:param exc: Pydantic 校验异常
|
||
:param limit: 最大错误数量
|
||
:return: 错误摘要
|
||
"""
|
||
formatted_errors = []
|
||
for error in exc.errors()[:limit]:
|
||
location = '.'.join(str(part) for part in error.get('loc', ())) or '<root>'
|
||
message = error.get('msg', '校验失败')
|
||
formatted_errors.append(f'{location}: {message}')
|
||
|
||
remaining_count = max(0, len(exc.errors()) - limit)
|
||
if remaining_count:
|
||
formatted_errors.append(f'另有 {remaining_count} 个错误')
|
||
|
||
return ';'.join(formatted_errors)
|
||
|
||
def _ensure_plugins_root_is_dir(self) -> None:
|
||
"""
|
||
校验插件根路径是目录。
|
||
|
||
:raises PluginManifestError: 根路径不是目录
|
||
:return: None
|
||
"""
|
||
if not self.plugins_root.is_dir():
|
||
raise PluginManifestError(f'插件根路径不是目录:{self.plugins_root}')
|
||
|
||
def _reject_unsupported_manifest_names_strict(self) -> None:
|
||
"""
|
||
严格模式下检查不支持的插件清单文件名,任一存在立即抛错。
|
||
|
||
:raises PluginManifestError: 存在不支持的清单文件名
|
||
:return: None
|
||
"""
|
||
for manifest_name in UNSUPPORTED_PLUGIN_MANIFEST_NAMES:
|
||
unsupported_manifest_paths = sorted(self.plugins_root.glob(f'*/{manifest_name}'))
|
||
if unsupported_manifest_paths:
|
||
raise PluginManifestError(
|
||
f'插件清单文件名必须为 {PLUGIN_MANIFEST_NAME}:{unsupported_manifest_paths[0]}'
|
||
)
|
||
|
||
def _collect_unsupported_manifest_name_errors(self) -> list[PluginDiscoveryError]:
|
||
"""
|
||
容错模式下收集不支持的清单文件名错误,按插件目录隔离。
|
||
|
||
:return: 发现错误列表
|
||
"""
|
||
errors: list[PluginDiscoveryError] = []
|
||
for manifest_name in UNSUPPORTED_PLUGIN_MANIFEST_NAMES:
|
||
errors.extend(
|
||
[
|
||
PluginDiscoveryError(
|
||
manifest_path=manifest_path,
|
||
plugin_dir=manifest_path.parent,
|
||
error_message=f'插件清单文件名必须为 {PLUGIN_MANIFEST_NAME}:{manifest_path}',
|
||
)
|
||
for manifest_path in sorted(self.plugins_root.glob(f'*/{manifest_name}'))
|
||
]
|
||
)
|
||
return errors
|
||
|
||
|
||
def discover_plugins(plugins_root: Path | str) -> list[DiscoveredPlugin]:
|
||
"""
|
||
便捷函数:严格扫描插件目录。
|
||
|
||
:param plugins_root: 后端插件根目录
|
||
:return: 已发现插件列表
|
||
:raises PluginManifestError: 任一插件清单错误
|
||
"""
|
||
return PluginScanner(plugins_root).discover()
|
||
|
||
|
||
def discover_plugins_with_errors(plugins_root: Path | str) -> PluginDiscoveryResult:
|
||
"""
|
||
便捷函数:容错扫描插件目录。
|
||
|
||
:param plugins_root: 后端插件根目录
|
||
:return: 插件发现结果
|
||
"""
|
||
return PluginScanner(plugins_root).discover_with_errors()
|