Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

500 lines
21 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 time import monotonic
from plugins.core.capability import STATE_CHANGE_OPERATIONS, PluginRuntimeCapability, PluginRuntimeCapabilityResolver
from plugins.core.discovery.registry import PluginRegistry
from plugins.core.discovery.scanner import (
DiscoveredPlugin,
PluginDiscoveryError,
PluginDiscoveryResult,
PluginScanner,
is_artifact_plugin,
)
from plugins.core.lifecycle.migration import PluginMigrationRunner
from plugins.core.lifecycle.precheck import PluginLifecycleScriptPrechecker
from plugins.core.runtime.support import PluginPrecheckContext
from plugins.core.types import PluginStateRecord
from plugins.core.validation.manifest import PluginManifestChecker, PluginManifestCheckResult
from plugins.core.validation.menus import PluginMenuConflictChecker
from plugins.core.validation.plugin_deps import (
PluginDependencyChecker as InterPluginDependencyChecker,
)
from plugins.core.validation.plugin_deps import (
PluginDependencyCheckResult,
)
from plugins.core.validation.result import PluginValidationIssue
from plugins.core.validation.structure import PluginStructureChecker
from utils.log_util import logger
from .dependency_container import PluginRuntimeDependencies
from .gateway import UnavailablePluginStateQueryGateway
from .migration_store import PluginMigrationHistoryGatewayStore
from .responses import PluginRuntimeBlockedPayload, PluginRuntimeBlockedPayloadDict
PLUGIN_DISCOVERY_CACHE_TTL_SECONDS = 2.0
DATABASE_PLUGIN_STATE_SYNC_LOOP_ERROR = '当前事件循环内不能同步读取数据库插件状态,已返回空列表'
class PluginRuntimeContextService:
"""
插件应用运行时上下文服务。
集中提供插件发现、注册表构建、数据库状态读取和预检上下文构建能力,
让 runtime facade 和组合式 use case 只关注插件操作编排。
"""
def __init__(self, dependencies: PluginRuntimeDependencies) -> None:
"""
初始化插件运行时上下文服务。
:param dependencies: 插件运行时依赖容器
"""
self.dependencies = dependencies
self._discovered_plugins_cache: dict[Path, tuple[float, list[DiscoveredPlugin]]] = {}
self._discovery_errors_cache: dict[Path, list[PluginDiscoveryError]] = {}
self._discovery_override: PluginDiscoveryResult | None = None
def set_discovery_snapshot(self, plugins: list[DiscoveredPlugin]) -> None:
"""
维护流程注入已验证目录,不修改全局扫描器或运行中进程的发现缓存。
:param plugins: 维护流程已验证的插件发现快照
:return: None
"""
ids = [plugin.manifest.id for plugin in plugins]
if len(ids) != len(set(ids)):
raise ValueError('插件发现快照包含重复 ID')
self._discovery_override = PluginDiscoveryResult(plugins=list(plugins))
def build_registry(self) -> PluginRegistry:
"""
构建本地插件注册表。
:return: 插件注册表
"""
backend_root = Path(self.dependencies.runtime_environment.get_backend_dir())
return PluginRegistry.build(self.discover_plugins(backend_root))
def get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None:
"""
根据插件 ID 获取已发现插件。
:param plugin_id: 插件ID
:return: 已发现插件对象
"""
backend_root = Path(self.dependencies.runtime_environment.get_backend_dir())
return self.get_discovered_plugin_from_list(self.discover_plugins(backend_root), plugin_id)
@staticmethod
def get_discovered_plugin_from_list(
discovered_plugins: list[DiscoveredPlugin],
plugin_id: str,
) -> DiscoveredPlugin | None:
"""
从已发现插件列表中查找指定插件。
:param discovered_plugins: 已发现插件列表
:param plugin_id: 插件ID
:return: 已发现插件对象
"""
for discovered_plugin in discovered_plugins:
if discovered_plugin.manifest.id == plugin_id:
return discovered_plugin
return None
async def load_database_plugin_state(self, plugin_id: str) -> tuple[PluginStateRecord | None, str | None]:
"""
读取数据库插件状态。
:param plugin_id: 插件ID
:return: 数据库插件状态和错误信息
"""
try:
return await self.dependencies.state_query_gateway.get_plugin_state(plugin_id), None
except Exception as exc:
logger.exception(f'读取数据库插件状态失败:{plugin_id},{exc}')
return None, str(exc)
async def guard_artifact_operation(
self, plugin_id: str, operation: str, *, dry_run: bool = False, check_capability: bool = True
) -> PluginRuntimeBlockedPayloadDict | None:
"""
数据库来源是最终依据,缺少源码目录不能授权清理已发布制品。
:param plugin_id: 待操作的插件 ID
:param operation: 待执行的生命周期操作名称
:param dry_run: 是否仅预览操作
:param check_capability: 是否同时检查已发现插件的运行时管理能力
:return: 制品操作拦截结果,允许操作时返回 None
"""
if self.dependencies.runtime_environment.get_backend_runtime_mode() == 'maintenance':
return None
discovered = self.get_discovered_plugin(plugin_id)
if discovered is not None and check_capability:
blocked = self.build_operation_blocked_payload(discovered, operation, dry_run=dry_run)
if blocked:
return blocked
if dry_run and isinstance(self.dependencies.state_query_gateway, UnavailablePluginStateQueryGateway):
return None
plugin, error = await self.load_database_plugin_state(plugin_id)
artifact = getattr(plugin, 'source', None) == 'artifact' or is_artifact_plugin(discovered)
if not artifact and not error:
return None
message = (
'签名制品插件只能通过维护发布命令变更,完成后重启全部worker'
if artifact
else '无法确认插件来源,已阻止生命周期变更,请恢复插件状态查询后重试'
)
return self._artifact_operation_blocked_payload(plugin_id, operation, message, dry_run=dry_run)
def build_dependency_install_event_loop_blocked_payload(self, plugin_id: str) -> PluginRuntimeBlockedPayloadDict:
"""
已有事件循环时,同步依赖安装不能另建循环查询同一个数据库连接池。
:param plugin_id: 待安装依赖的插件 ID
:return: 阻止在已有事件循环内同步安装依赖的结果载荷
"""
return self._artifact_operation_blocked_payload(
plugin_id,
'dependency_install',
'当前事件循环不能同步确认插件来源,请使用维护CLI执行依赖安装',
)
@staticmethod
def _artifact_operation_blocked_payload(
plugin_id: str, operation: str, message: str, *, dry_run: bool = False
) -> PluginRuntimeBlockedPayloadDict:
"""
构建制品生命周期操作的统一拦截结果。
:param plugin_id: 待操作的插件 ID
:param operation: 被拦截的操作名称
:param message: 当前操作的拦截原因
:param dry_run: 是否仅预览操作
:return: 包含操作能力及维护建议的拦截结果载荷
"""
return PluginRuntimeBlockedPayload(
ok=False,
status='blocked',
operation=operation,
plugin_id=plugin_id,
message=message,
suggestion='请使用 plugin release 命令,并在执行前停止全部宿主worker。',
capability={
'pluginId': plugin_id,
'backendRuntimeManageable': False,
'runtimeManageable': False,
'blockedOperations': sorted(STATE_CHANGE_OPERATIONS),
'warnings': [message],
'primaryReason': message,
},
dry_run=dry_run,
exit_code=1,
).to_payload(exclude_none=True)
async def get_readonly_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None:
"""
配置读取可解析已选且重新验签的制品清单,不执行任何入口。
:param plugin_id: 待读取配置的插件 ID
:return: 已发现源码插件或重新验签的当前制品,未找到时返回 None
"""
if isinstance(self.dependencies.state_query_gateway, UnavailablePluginStateQueryGateway):
return self.get_discovered_plugin(plugin_id)
plugin, error = await self.load_database_plugin_state(plugin_id)
if error:
raise ValueError('无法确认插件来源,配置读取已中止')
if getattr(plugin, 'source', None) != 'artifact':
return self.get_discovered_plugin(plugin_id)
resolver = getattr(self.dependencies.state_query_gateway, 'get_selected_artifact_plugin', None)
if not callable(resolver):
raise ValueError('插件状态适配器未提供已选制品的只读解析能力')
return await resolver(plugin_id, Path(self.dependencies.runtime_environment.get_backend_dir()))
async def load_database_plugin_states_with_error(self) -> tuple[list[PluginStateRecord], str | None]:
"""
读取数据库插件状态列表,并保留失败原因。
:return: 数据库插件状态列表和错误信息
"""
try:
return await self.dependencies.state_query_gateway.list_plugin_states(), None
except Exception as exc:
logger.exception(f'读取数据库插件状态列表失败:{exc}')
return [], str(exc)
async def load_database_plugin_states(self) -> list[PluginStateRecord]:
"""
读取数据库插件状态列表。
:return: 数据库插件状态列表
"""
database_plugins, _database_error = await self.load_database_plugin_states_with_error()
return database_plugins
def load_database_plugin_states_sync_with_error(self) -> tuple[list[PluginStateRecord], str | None]:
"""
以同步方式读取数据库插件状态列表,并保留失败原因。
:return: 数据库插件状态列表和错误信息
"""
if not self.has_plugin_dependencies():
return [], None
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(self.load_database_plugin_states_with_error())
logger.warning(DATABASE_PLUGIN_STATE_SYNC_LOOP_ERROR)
return [], DATABASE_PLUGIN_STATE_SYNC_LOOP_ERROR
def load_database_plugin_states_sync(self) -> list[PluginStateRecord]:
"""
以同步方式读取数据库插件状态列表。
:return: 数据库插件状态列表
"""
database_plugins, _database_error = self.load_database_plugin_states_sync_with_error()
return database_plugins
def has_plugin_dependencies(self) -> bool:
"""
判断当前本地插件是否声明了插件间依赖。
:return: 是否存在插件间依赖声明
"""
backend_root = Path(self.dependencies.runtime_environment.get_backend_dir())
return any(plugin.manifest.dependencies.plugins for plugin in self.discover_plugins(backend_root))
def resolve_plugin_capability(self, discovered_plugin: DiscoveredPlugin) -> PluginRuntimeCapability:
"""
解析插件运行时操作能力。
:param discovered_plugin: 已发现插件
:return: 插件运行时能力
"""
return PluginRuntimeCapabilityResolver(
frontend_mode=self.dependencies.runtime_environment.get_frontend_mode(),
backend_runtime_mode=self.dependencies.runtime_environment.get_backend_runtime_mode(),
).resolve(discovered_plugin)
def with_plugin_capability(
self,
payload: dict[str, object],
discovered_plugin: DiscoveredPlugin | None,
) -> dict[str, object]:
"""
为运行时响应负载附加插件操作能力。
:param payload: 运行时响应负载
:param discovered_plugin: 已发现插件
:return: 附加能力后的响应负载
"""
if discovered_plugin:
payload['capability'] = self.resolve_plugin_capability(discovered_plugin).to_payload()
return payload
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
"""
capability = self.resolve_plugin_capability(discovered_plugin)
if capability.allows(operation):
return None
return PluginRuntimeBlockedPayload(
ok=False,
status='blocked',
operation=operation,
plugin_id=discovered_plugin.manifest.id,
message='当前环境不允许执行该插件操作',
suggestion='请在开发模式或维护窗口中执行插件变更,并重启后端或重新构建前端。',
capability=capability.to_payload(),
dry_run=dry_run,
exit_code=1,
).to_payload(exclude_none=True)
async def check_inter_plugin_dependencies(
self,
discovered_plugin: DiscoveredPlugin,
discovered_plugins: list[DiscoveredPlugin],
) -> PluginDependencyCheckResult:
"""
检查插件间依赖。
:param discovered_plugin: 当前已发现插件
:param discovered_plugins: 全量已发现插件列表
:return: 插件间依赖检查结果
"""
if not discovered_plugin.manifest.dependencies.plugins:
return PluginDependencyCheckResult(plugin_id=discovered_plugin.manifest.id, items=[])
database_plugins = await self.load_database_plugin_states()
return InterPluginDependencyChecker(discovered_plugins, database_plugins).check_manifest(
discovered_plugin.manifest
)
async def check_enabled_plugin_dependents(
self,
plugin_id: str,
discovered_plugins: list[DiscoveredPlugin],
) -> PluginDependencyCheckResult:
"""
检查指定插件是否仍被已启用插件依赖。
:param plugin_id: 被停用或卸载的插件ID
:param discovered_plugins: 全量已发现插件列表
:return: 被依赖方检查结果
"""
has_direct_dependents = any(
dependency.id == plugin_id
for discovered_plugin in discovered_plugins
for dependency in discovered_plugin.manifest.dependencies.plugins
)
if not has_direct_dependents:
return PluginDependencyCheckResult(plugin_id=plugin_id, items=[])
database_plugins = await self.load_database_plugin_states()
return InterPluginDependencyChecker(discovered_plugins, database_plugins).check_enabled_dependents(plugin_id)
async def build_precheck_context(
self,
backend_root: Path,
discovered_plugin: DiscoveredPlugin,
discovered_plugins: list[DiscoveredPlugin],
) -> PluginPrecheckContext:
"""
构建插件操作预检上下文。
:param backend_root: 后端项目根目录
:param discovered_plugin: 当前插件
:param discovered_plugins: 已发现插件列表
:return: 插件操作预检上下文
"""
frontend_root = Path(self.dependencies.runtime_environment.get_frontend_dir())
frontend_plugins_root = Path(self.dependencies.runtime_environment.get_frontend_plugins_dir())
dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest)
manifest_result = PluginManifestChecker(backend_root=backend_root, frontend_root=frontend_root).check(
discovered_plugin.manifest
)
manifest_result = await self._check_lifecycle_scripts(discovered_plugin, manifest_result)
plugin_dependency_result = await self.check_inter_plugin_dependencies(discovered_plugin, discovered_plugins)
structure_result = PluginStructureChecker(backend_root, frontend_plugins_root).check(discovered_plugin)
menu_conflict_result = PluginMenuConflictChecker().check(discovered_plugin, discovered_plugins)
return PluginPrecheckContext.build(
dependency_result,
manifest_result,
plugin_dependency_result,
structure_result,
menu_conflict_result,
)
async def _check_lifecycle_scripts(
self,
discovered_plugin: DiscoveredPlugin,
manifest_result: PluginManifestCheckResult,
) -> PluginManifestCheckResult:
"""
检查 migration 历史和 seed 执行计划,并合并到 manifest 预检结果。
:param discovered_plugin: 当前插件
:param manifest_result: 原始 manifest 检查结果
:return: 合并生命周期脚本预检后的 manifest 检查结果
"""
if not discovered_plugin.manifest.backend.migrations and not discovered_plugin.manifest.backend.seeds:
return manifest_result
try:
migration_runner = PluginMigrationRunner(
discovered_plugin,
PluginMigrationHistoryGatewayStore(self.dependencies.migration_history_gateway),
)
script_result = await PluginLifecycleScriptPrechecker(discovered_plugin, migration_runner).check(object())
except Exception as exc:
logger.exception(f'插件生命周期脚本预检失败:{discovered_plugin.manifest.id},{exc}')
return PluginManifestCheckResult(
plugin_id=manifest_result.plugin_id,
issues=[
*manifest_result.issues,
PluginRuntimeContextService._build_lifecycle_script_precheck_issue(exc),
],
)
return PluginManifestCheckResult(
plugin_id=manifest_result.plugin_id,
issues=[*manifest_result.issues, *script_result.issues],
)
@staticmethod
def _build_lifecycle_script_precheck_issue(exc: Exception) -> PluginValidationIssue:
"""
构建生命周期脚本预检异常问题。
:param exc: 原始异常
:return: 统一校验问题
"""
return PluginValidationIssue(
level='error',
category='lifecycle',
kind='lifecycle_script_precheck_failed',
path='backend',
message=f'插件生命周期脚本预检失败:{exc}',
suggestion='请检查 migration/seed 声明和脚本文件',
)
def discover_plugins(self, backend_root: Path) -> list[DiscoveredPlugin]:
"""
发现本地插件。
使用容错扫描,单个损坏插件不会影响其他插件。损坏插件的错误明细可通过
:meth:`get_discovery_errors` 获取。
:param backend_root: 后端项目根目录
:return: 已发现插件列表
"""
return self.discover_plugins_with_errors(backend_root).plugins
def discover_plugins_with_errors(self, backend_root: Path) -> PluginDiscoveryResult:
"""
发现本地插件并返回错误明细。
:param backend_root: 后端项目根目录
:return: 插件发现结果
"""
if self._discovery_override is not None:
return PluginDiscoveryResult(plugins=list(self._discovery_override.plugins))
resolved_backend_root = backend_root.resolve()
cached_entry = self._discovered_plugins_cache.get(resolved_backend_root)
if cached_entry is not None:
cached_at, cached_plugins = cached_entry
if monotonic() - cached_at <= PLUGIN_DISCOVERY_CACHE_TTL_SECONDS:
cached_errors = self._discovery_errors_cache.get(resolved_backend_root, [])
return PluginDiscoveryResult(plugins=list(cached_plugins), errors=list(cached_errors))
discovery_result = PluginScanner(resolved_backend_root / 'plugins').discover_with_errors()
self._discovered_plugins_cache[resolved_backend_root] = (monotonic(), discovery_result.plugins)
self._discovery_errors_cache[resolved_backend_root] = list(discovery_result.errors)
for error in discovery_result.errors:
logger.warning(f'插件扫描失败,已隔离损坏插件:目录={error.plugin_dir},错误:{error.error_message}')
return discovery_result
def get_discovery_errors(self, backend_root: Path) -> list[PluginDiscoveryError]:
"""
获取本地插件扫描错误明细。
:param backend_root: 后端项目根目录
:return: 扫描错误明细列表
"""
self.discover_plugins_with_errors(backend_root)
return list(self._discovery_errors_cache.get(backend_root.resolve(), []))