Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.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

377 lines
16 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 asyncio
from pathlib import Path
from typing import cast
from plugins.core.discovery.scanner import DiscoveredPlugin
from plugins.core.runtime.support import (
PluginDependencyInstallPayloadBuilder,
PluginNpmPackageJsonSynchronizer,
PluginPayloadBuilder,
PluginRuntimePayloadBuilder,
)
from plugins.core.validation.dependencies import (
DependencyCheckResult,
PluginDependencyInstallPlanner,
)
from plugins.core.validation.dependency_policy import (
DependencyInstallPolicyConfig,
DependencyInstallPolicyEvaluator,
)
from .context import PluginRuntimeContextService
from .dependency_container import PluginRuntimeDependencies
from .gateway import PluginCommandOutputCallback
from .responses import PluginDependencyInstallResponse, PluginRuntimeBlockedPayloadDict
PLUGIN_DEPENDENCY_INSTALL_TIMEOUT_SECONDS = 600
class PluginDependencyUseCase:
"""
插件 Python/npm 依赖检查和安装 use case。
"""
def __init__(self, dependencies: PluginRuntimeDependencies, context: PluginRuntimeContextService) -> None:
"""
初始化插件依赖 use case。
:param dependencies: 插件运行时依赖容器
:param context: 插件运行时上下文服务
"""
self.dependencies = dependencies
self.context = context
def _get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None:
"""
根据插件 ID 获取已发现插件。
:param plugin_id: 插件ID
:return: 已发现插件对象
"""
return self.context.get_discovered_plugin(plugin_id)
def _build_operation_blocked_payload(
self,
discovered_plugin: DiscoveredPlugin,
operation: str,
*,
dry_run: bool | None = None,
) -> PluginRuntimeBlockedPayloadDict | None:
"""
构建运行模式阻断负载。
:param discovered_plugin: 已发现插件
:param operation: 操作类型
:param dry_run: 是否预演
:return: 阻断负载,不阻断时返回 None
"""
return cast(
'PluginRuntimeBlockedPayloadDict | None',
self.context.build_operation_blocked_payload(discovered_plugin, operation, dry_run=dry_run),
)
def _with_plugin_capability(
self,
payload: dict[str, object],
discovered_plugin: DiscoveredPlugin | None,
) -> dict[str, object]:
"""
为运行时响应负载附加插件操作能力。
:param payload: 运行时响应负载
:param discovered_plugin: 已发现插件
:return: 附加能力后的响应负载
"""
return cast('dict[str, object]', self.context.with_plugin_capability(payload, discovered_plugin))
@staticmethod
def _with_dependency_install_metadata(
payload: dict[str, object],
*,
confirmed: bool,
) -> dict[str, object]:
"""
为 standalone 依赖安装审计补充稳定操作元数据。
:param payload: 依赖安装负载
:param confirmed: 是否已显式确认
:return: 补充元数据后的负载
"""
payload['operation'] = 'dependency_install'
payload['confirmed'] = confirmed
return payload
def install_plugin_dependencies(
self,
plugin_id: str,
*,
dry_run: bool = False,
policy_config: DependencyInstallPolicyConfig | None = None,
confirmed: bool = False,
output_callback: PluginCommandOutputCallback | None = None,
) -> PluginDependencyInstallResponse:
"""
安装插件依赖。
:param plugin_id: 插件ID
:param dry_run: 是否仅预演
:param policy_config: 依赖安装策略配置
:param confirmed: 是否已显式确认
:param output_callback: 依赖安装实时输出回调
:return: 插件依赖安装负载
"""
try:
discovered_plugin = self._get_discovered_plugin(plugin_id)
if not discovered_plugin:
return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id, dry_run=dry_run)
blocked_payload = self._build_operation_blocked_payload(
discovered_plugin,
'dependency_install',
dry_run=dry_run,
)
if blocked_payload:
return blocked_payload
dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest)
return self.install_plugin_dependencies_from_result(
plugin_id,
dependency_result,
dry_run=dry_run,
discovered_plugin=discovered_plugin,
policy_config=policy_config,
confirmed=confirmed,
output_callback=output_callback,
)
except Exception as exc:
return PluginRuntimePayloadBuilder.build_exception_payload('安装插件依赖失败', exc)
def install_plugin_dependencies_from_result(
self,
plugin_id: str,
dependency_result: DependencyCheckResult,
*,
dry_run: bool = False,
discovered_plugin: DiscoveredPlugin | None = None,
policy_config: DependencyInstallPolicyConfig | None = None,
confirmed: bool = False,
output_callback: PluginCommandOutputCallback | None = None,
) -> PluginDependencyInstallResponse:
"""
根据既有依赖检查结果生成计划并执行依赖安装。
:param plugin_id: 插件ID
:param dependency_result: 依赖检查结果
:param dry_run: 是否仅预演
:param discovered_plugin: 已发现插件,传入后避免重复扫描插件目录
:param policy_config: 依赖安装策略配置
:param confirmed: 是否已显式确认
:param output_callback: 依赖安装实时输出回调
:return: 插件依赖安装负载
"""
discovered_plugin = discovered_plugin or self._get_discovered_plugin(plugin_id)
install_plan = PluginDependencyInstallPlanner(
frontend_root=Path(self.dependencies.runtime_environment.get_frontend_dir())
).build_plan(dependency_result)
resolved_policy_config = policy_config or DependencyInstallPolicyConfig.from_environment()
policy_decision = DependencyInstallPolicyEvaluator(resolved_policy_config).evaluate(
install_plan,
confirmed=confirmed,
)
install_plan_items = policy_decision.install_plan_items
if dry_run:
payload = PluginDependencyInstallPayloadBuilder.build_dry_run_payload(
plugin_id,
dependency_result,
install_plan_items,
policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
if not install_plan.has_actions:
payload = PluginDependencyInstallPayloadBuilder.build_satisfied_payload(
plugin_id,
dependency_result,
install_plan_items,
policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
if not policy_decision.allowed:
payload = PluginDependencyInstallPayloadBuilder.build_payload(
plugin_id=plugin_id,
dependency_result=dependency_result,
install_plan_items=install_plan_items,
dry_run=False,
ok=False,
message='插件依赖安装被策略阻断',
policy_decision=policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
install_results = []
total = len(install_plan_items)
for index, item in enumerate(install_plan_items, start=1):
self._emit_install_status(output_callback, index, total, item.requirement, '开始安装')
try:
completed = self.dependencies.command_gateway.run_command(
item.command,
item.workdir,
timeout=resolved_policy_config.install_timeout_seconds,
output_callback=output_callback,
)
except Exception:
self._emit_install_status(output_callback, index, total, item.requirement, '安装中断')
raise
install_results.append(PluginPayloadBuilder.build_dependency_install_result(item, completed))
status = '安装完成' if completed.returncode == 0 else f'安装失败(退出码 {completed.returncode})'
self._emit_install_status(output_callback, index, total, item.requirement, status)
PluginNpmPackageJsonSynchronizer.sync_successful_items(install_plan_items, install_results)
payload = PluginDependencyInstallPayloadBuilder.build_execution_payload(
plugin_id,
dependency_result,
install_plan_items,
install_results,
policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
async def install_plugin_dependencies_from_result_async(
self,
plugin_id: str,
dependency_result: DependencyCheckResult,
*,
dry_run: bool = False,
discovered_plugin: DiscoveredPlugin | None = None,
policy_config: DependencyInstallPolicyConfig | None = None,
confirmed: bool = False,
output_callback: PluginCommandOutputCallback | None = None,
) -> PluginDependencyInstallResponse:
"""
根据既有依赖检查结果异步生成计划并执行依赖安装。
:param plugin_id: 插件ID
:param dependency_result: 依赖检查结果
:param dry_run: 是否仅预演
:param discovered_plugin: 已发现插件,传入后避免重复扫描插件目录
:param policy_config: 依赖安装策略配置
:param confirmed: 是否已显式确认
:param output_callback: 依赖安装实时输出回调
:return: 插件依赖安装负载
"""
discovered_plugin = discovered_plugin or self._get_discovered_plugin(plugin_id)
install_plan = PluginDependencyInstallPlanner(
frontend_root=Path(self.dependencies.runtime_environment.get_frontend_dir())
).build_plan(dependency_result)
resolved_policy_config = policy_config or DependencyInstallPolicyConfig.from_environment()
policy_decision = DependencyInstallPolicyEvaluator(resolved_policy_config).evaluate(
install_plan,
confirmed=confirmed,
)
install_plan_items = policy_decision.install_plan_items
if dry_run:
payload = PluginDependencyInstallPayloadBuilder.build_dry_run_payload(
plugin_id,
dependency_result,
install_plan_items,
policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
if not install_plan.has_actions:
payload = PluginDependencyInstallPayloadBuilder.build_satisfied_payload(
plugin_id,
dependency_result,
install_plan_items,
policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
if not policy_decision.allowed:
payload = PluginDependencyInstallPayloadBuilder.build_payload(
plugin_id=plugin_id,
dependency_result=dependency_result,
install_plan_items=install_plan_items,
dry_run=False,
ok=False,
message='插件依赖安装被策略阻断',
policy_decision=policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
install_results = []
total = len(install_plan_items)
for index, item in enumerate(install_plan_items, start=1):
self._emit_install_status(output_callback, index, total, item.requirement, '开始安装')
try:
completed = await asyncio.to_thread(
self.dependencies.command_gateway.run_command,
item.command,
item.workdir,
timeout=resolved_policy_config.install_timeout_seconds,
output_callback=output_callback,
)
except Exception:
self._emit_install_status(output_callback, index, total, item.requirement, '安装中断')
raise
install_results.append(PluginPayloadBuilder.build_dependency_install_result(item, completed))
status = '安装完成' if completed.returncode == 0 else f'安装失败(退出码 {completed.returncode})'
self._emit_install_status(output_callback, index, total, item.requirement, status)
PluginNpmPackageJsonSynchronizer.sync_successful_items(install_plan_items, install_results)
payload = PluginDependencyInstallPayloadBuilder.build_execution_payload(
plugin_id,
dependency_result,
install_plan_items,
install_results,
policy_decision,
)
payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed)
return cast(
'PluginDependencyInstallResponse',
self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin),
)
@staticmethod
def _emit_install_status(
output_callback: PluginCommandOutputCallback | None,
index: int,
total: int,
requirement: str,
status: str,
) -> None:
"""
输出单项依赖安装状态。
:param output_callback: 依赖安装实时输出回调
:param index: 当前安装项序号
:param total: 安装项总数
:param requirement: 依赖声明
:param status: 当前状态
:return: None
"""
if output_callback is not None:
output_callback('status', f'[{index}/{total}] {status}:{requirement}\n')