mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-16 05:45:32 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
279 lines
10 KiB
Python
279 lines
10 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
|
||
artifact_digest: str | None = None
|
||
artifact_generation: str | None = None
|
||
|
||
|
||
def is_artifact_plugin(plugin: DiscoveredPlugin) -> bool:
|
||
"""
|
||
只有显式的非空字符串摘要表示制品来源,兼容旧扫描器返回对象。
|
||
|
||
:param plugin: 待判断的插件发现对象
|
||
:return: 插件是否来自带摘要的签名制品
|
||
"""
|
||
digest = getattr(plugin, 'artifact_digest', None)
|
||
return isinstance(digest, str) and bool(digest)
|
||
|
||
|
||
@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()
|