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

1107 lines
43 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.
from collections.abc import Mapping
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from plugins.core.capability import STATE_CHANGE_OPERATIONS, PluginRuntimeCapabilityResolver
from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner
from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT
from plugins.core.lifecycle.jobs import PluginJobInstaller, PluginJobRepository
from plugins.core.lifecycle.purge import PluginPurgePlan, PluginPurgePlanner
from plugins.core.management.dao.dao import PluginDao
from plugins.core.management.entity.vo.schemas import (
PluginConfigModel,
PluginConfigUpdateModel,
PluginConfigValueModel,
PluginMenuModel,
PluginMigrationModel,
PluginModel,
PluginOperationLogDetailModel,
PluginOperationLogExportQueryModel,
PluginOperationLogModel,
PluginOperationLogPageQueryModel,
PluginOperationLogRetentionModel,
PluginOperationLogRetentionResultModel,
PluginPageQueryModel,
PluginStatus,
)
from plugins.core.management.service.config import PluginConfigManager
from plugins.core.management.service.logs import PluginOperationLogBuilder
from plugins.core.management.service.menus import PluginMenuInstaller
from plugins.core.manifest.menu_tree import PluginMenuTree
from plugins.core.state import PluginStateResolver, PluginStateSnapshot, PluginStateTransitionTable
from plugins.core.validation.dependencies import PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX
from plugins.core.validation.menus import PluginMenuConflictItem
from utils.common_util import CamelCaseUtil
from utils.excel_util import ExcelUtil
from utils.page_util import PageUtil
class PluginService:
"""
插件系统服务层。
"""
ORPHAN_PLUGIN_REASON = '插件源码不存在,仅允许物理清理平台元数据'
@classmethod
async def get_plugin_list_services(cls, query_db: AsyncSession) -> list[PluginModel]:
"""
获取插件列表。
:param query_db: orm对象
:return: 插件信息列表
"""
plugin_list = await PluginDao.get_plugin_list(query_db)
return [PluginModel(**CamelCaseUtil.transform_result(plugin)) for plugin in plugin_list]
@classmethod
async def get_plugin_page_list_services(
cls,
query_db: AsyncSession,
query_object: PluginPageQueryModel,
is_page: bool = True,
*,
backend_root: Path | None = None,
frontend_root: Path | None = None,
) -> PageModel | list[dict[str, Any]]:
"""
获取插件分页列表。
:param query_db: orm对象
:param query_object: 插件分页查询对象
:param is_page: 是否开启分页
:param backend_root: 后端插件根目录
:param frontend_root: 前端插件根目录
:return: 插件分页列表或插件列表
"""
backend_root = backend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_backend_plugins_dir())
frontend_root = frontend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_plugins_dir())
discovered_plugins = PluginScanner(backend_root).discover()
database_plugins = await PluginDao.get_plugin_list(query_db)
database_plugin_map = {plugin.plugin_id: plugin for plugin in database_plugins}
discovered_plugin_ids = {plugin.manifest.id for plugin in discovered_plugins}
plugin_items = [
cls._build_plugin_model(
discovered_plugin,
backend_root,
frontend_root,
database_plugin_map.get(discovered_plugin.manifest.id),
).model_dump(by_alias=True)
for discovered_plugin in discovered_plugins
]
plugin_items.extend(
cls._build_orphan_plugin_model(plugin).model_dump(by_alias=True)
for plugin in database_plugins
if plugin.plugin_id not in discovered_plugin_ids
)
plugin_items = cls._filter_plugin_page_items(plugin_items, query_object)
if is_page:
return PageUtil.get_page_obj(plugin_items, query_object.page_num, query_object.page_size)
return plugin_items
@classmethod
async def plugin_detail_services(
cls,
query_db: AsyncSession,
plugin_id: str,
*,
backend_root: Path | None = None,
frontend_root: Path | None = None,
) -> PluginModel | None:
"""
获取插件详情。
:param query_db: orm对象
:param plugin_id: 插件ID
:param backend_root: 后端插件根目录
:param frontend_root: 前端插件根目录
:return: 插件信息对象
"""
backend_root = backend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_backend_plugins_dir())
frontend_root = frontend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_plugins_dir())
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
discovered_plugin = cls._get_discovered_plugin(backend_root, plugin_id)
if discovered_plugin:
return cls._build_plugin_model(discovered_plugin, backend_root, frontend_root, plugin)
return cls._build_orphan_plugin_model(plugin) if plugin else None
@classmethod
async def upsert_discovered_plugin_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
backend_root: Path,
frontend_root: Path | None = None,
) -> PluginModel:
"""
写入或更新已发现插件。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:param backend_root: 后端插件根目录
:param frontend_root: 前端插件根目录
:return: 插件信息对象
"""
manifest = discovered_plugin.manifest
existing_plugin = await PluginDao.get_plugin_by_id(query_db, manifest.id)
plugin_model = cls._build_plugin_model(discovered_plugin, backend_root, frontend_root, existing_plugin)
if existing_plugin:
await PluginDao.update_plugin(query_db, PluginDao.dump_plugin_persistence_payload(plugin_model))
else:
await PluginDao.add_plugin(query_db, plugin_model)
return plugin_model
@classmethod
async def update_plugin_enabled_services(
cls,
query_db: AsyncSession,
plugin_id: str,
enabled: bool,
discovered_plugin: DiscoveredPlugin | None = None,
) -> CrudResponseModel:
"""
更新插件启停状态。
:param query_db: orm对象
:param plugin_id: 插件ID
:param enabled: 是否启用
:param discovered_plugin: 已发现插件对象,用于启用时恢复声明任务
:return: 操作响应
"""
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
if not plugin:
return CrudResponseModel(is_success=False, message='插件不存在')
if enabled and not getattr(plugin, 'installed_version', None):
return CrudResponseModel(is_success=False, message='插件尚未安装,不能启用')
operation = 'enable' if enabled else 'disable'
status = PluginStateTransitionTable.resolve_target(getattr(plugin, 'status', None), operation)
if status is None:
return CrudResponseModel(is_success=False, message='插件状态不允许执行当前启停操作')
enabled_value = PluginStateResolver.enabled_to_db_value(enabled)
update_payload = {
'plugin_id': plugin_id,
'enabled': enabled_value,
'status': status,
'update_time': datetime.now(),
}
if enabled:
update_payload['last_error'] = None
await PluginDao.update_plugin(query_db, update_payload)
await PluginMenuInstaller(query_db).set_plugin_menu_status(plugin_id, '0' if enabled else '1')
job_installer = PluginJobInstaller(query_db)
if enabled and discovered_plugin:
await job_installer.install_plugin_jobs(discovered_plugin, enabled=True)
elif not enabled:
await job_installer.pause_plugin_jobs(plugin_id)
return CrudResponseModel(is_success=True, message='启用成功' if enabled else '停用成功')
@classmethod
async def mark_plugin_installed_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
) -> PluginModel:
"""
标记插件安装完成。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:return: 插件信息对象
"""
manifest = discovered_plugin.manifest
plugin = await PluginDao.get_plugin_by_id(query_db, manifest.id)
enabled = getattr(plugin, 'enabled', None) if plugin else '0'
status = PluginStateTransitionTable.resolve_target(getattr(plugin, 'status', None), 'install') or 'installed'
update_payload = {
'plugin_id': manifest.id,
'version': manifest.version,
'installed_version': manifest.version,
'status': status,
'last_error': None,
'update_time': datetime.now(),
}
await PluginDao.update_plugin(query_db, update_payload)
return PluginModel(
pluginId=manifest.id,
pluginName=manifest.name,
version=manifest.version,
installedVersion=manifest.version,
enabled=enabled,
status=status,
source='local',
backendPath=str(discovered_plugin.backend_path),
description=manifest.description,
updateTime=update_payload['update_time'],
)
@classmethod
async def mark_plugin_uninstalled_services(cls, query_db: AsyncSession, plugin_id: str) -> CrudResponseModel:
"""
标记插件已卸载。
卸载不同于停用:停用保留 installed_version,卸载清空安装版本,使本地插件回到可安装状态。
:param query_db: orm对象
:param plugin_id: 插件ID
:return: 操作响应
"""
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
if not plugin:
return CrudResponseModel(is_success=False, message='插件不存在')
plugin_menus = await PluginDao.get_plugin_menu_list(query_db, plugin_id)
menu_ids = [plugin_menu.menu_id for plugin_menu in plugin_menus]
update_payload = {
'plugin_id': plugin_id,
'installed_version': None,
'enabled': '1',
'status': 'discovered',
'last_error': None,
'update_time': datetime.now(),
}
await PluginDao.update_plugin(query_db, update_payload)
await PluginDao.delete_plugin_menus(query_db, plugin_id)
await PluginDao.delete_sys_menus_by_ids(query_db, menu_ids)
await PluginJobInstaller(query_db).pause_plugin_jobs(plugin_id)
return CrudResponseModel(is_success=True, message='卸载成功')
@classmethod
async def install_plugin_menu_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
*,
enabled: bool,
) -> None:
"""
安装指定插件菜单并设置菜单启停状态。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:param enabled: 插件菜单是否启用
:return: None
"""
menu_installer = PluginMenuInstaller(query_db)
await menu_installer.install_manifest_menus(discovered_plugin.manifest)
await menu_installer.set_plugin_menu_status(discovered_plugin.manifest.id, '0' if enabled else '1')
@classmethod
async def install_plugin_default_config_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
) -> list[PluginConfigModel]:
"""
安装插件默认配置。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:return: 插件配置模型列表
"""
installed_configs = []
manifest = discovered_plugin.manifest
existing_configs = await PluginDao.get_plugin_config_list(query_db, manifest.id)
existing_config_map = {config.config_key: config for config in existing_configs}
desired_config_keys = {item.key for item in manifest.config.items}
for item in manifest.config.items:
existing_config = existing_config_map.get(item.key)
config_model = PluginConfigManager.build_config_model(manifest.id, item)
if existing_config:
migrated_config_value = PluginConfigManager.migrate_config_secret_storage(existing_config, item)
await PluginDao.update_plugin_config(
query_db,
{
'plugin_id': manifest.id,
'config_key': item.key,
'config_label': config_model.config_label,
'config_type': config_model.config_type,
'config_value': migrated_config_value,
'default_value': config_model.default_value,
'required': config_model.required,
'secret': config_model.secret,
'options': config_model.options,
'description': config_model.description,
'update_time': datetime.now(),
},
)
else:
await PluginDao.add_plugin_config(query_db, config_model)
installed_configs.append(config_model)
await PluginDao.delete_plugin_configs_except(query_db, manifest.id, desired_config_keys)
return installed_configs
@classmethod
async def install_plugin_job_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
*,
enabled: bool,
) -> None:
"""
将单个插件的任务资源同步到 manifest 期望状态。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:param enabled: 插件任务是否允许启用
:return: None
"""
await PluginJobInstaller(query_db).install_plugin_jobs(discovered_plugin, enabled=enabled)
@classmethod
async def get_plugin_config_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
*,
reveal_secret: bool = False,
) -> list[PluginConfigValueModel]:
"""
获取插件配置列表。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:param reveal_secret: 是否展示敏感配置原值
:return: 插件配置值列表
"""
config_list = await PluginDao.get_plugin_config_list(query_db, discovered_plugin.manifest.id)
config_map = {config.config_key: config for config in config_list}
return [
PluginConfigManager.build_config_value(
config_map.get(item.key) or PluginConfigManager.build_config_model(discovered_plugin.manifest.id, item),
item,
reveal_secret=reveal_secret,
)
for item in discovered_plugin.manifest.config.items
]
@classmethod
async def is_plugin_installed_services(cls, query_db: AsyncSession, plugin_id: str) -> bool:
"""
判断插件是否已经完成安装。
:param query_db: orm对象
:param plugin_id: 插件ID
:return: 是否已安装
"""
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
return bool(plugin and plugin.installed_version)
@classmethod
async def update_plugin_config_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
update_model: PluginConfigUpdateModel,
) -> list[PluginConfigValueModel]:
"""
更新插件配置。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:param update_model: 插件配置更新模型
:return: 更新后的插件配置值列表
"""
await cls.install_plugin_default_config_services(query_db, discovered_plugin)
manifest_items = {item.key: item for item in discovered_plugin.manifest.config.items}
for key, value in update_model.values.items():
item = manifest_items.get(key)
if not item:
raise ValueError(f'插件未声明配置:{key}')
if item.secret and value == PluginConfigManager.MASK_VALUE:
continue
PluginConfigManager.validate_update_value(item, value)
await PluginDao.update_plugin_config(
query_db,
{
'plugin_id': discovered_plugin.manifest.id,
'config_key': key,
'config_value': PluginConfigManager.serialize_config_value(value, secret=item.secret),
'update_time': datetime.now(),
},
)
return await cls.get_plugin_config_services(query_db, discovered_plugin)
@classmethod
async def add_plugin_operation_log_services(
cls,
query_db: AsyncSession,
payload: Mapping[str, object],
*,
dry_run: bool,
continue_on_error: bool,
) -> PluginOperationLogModel:
"""
记录插件批量操作审计日志。
:param query_db: orm对象
:param payload: 插件批量执行结果负载
:param dry_run: 是否预演
:param continue_on_error: 失败后是否继续执行后续插件
:return: 插件批量操作审计日志模型
"""
operation_log = PluginOperationLogBuilder.build_model(
payload,
dry_run=dry_run,
continue_on_error=continue_on_error,
)
db_operation_log = await PluginDao.add_plugin_operation_log(query_db, operation_log)
return PluginOperationLogModel(**CamelCaseUtil.transform_result(db_operation_log))
@classmethod
async def get_plugin_operation_log_page_list_services(
cls,
query_db: AsyncSession,
query_object: PluginOperationLogPageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
"""
获取插件批量操作审计日志分页列表。
:param query_db: orm对象
:param query_object: 插件批量操作审计日志分页查询对象
:param is_page: 是否开启分页
:return: 插件批量操作审计日志分页列表或列表
"""
page_result = await PluginDao.get_plugin_operation_log_page_list(query_db, query_object, is_page)
if not isinstance(page_result, PageModel):
return page_result
page_result.rows = [PluginOperationLogBuilder.build_detail(row) for row in page_result.rows]
return page_result
@classmethod
async def get_plugin_operation_log_export_list_services(
cls,
query_db: AsyncSession,
query_object: PluginOperationLogExportQueryModel,
) -> list[PluginOperationLogDetailModel]:
"""
获取插件批量操作审计日志导出列表。
:param query_db: orm对象
:param query_object: 插件批量操作审计日志导出查询对象
:return: 插件批量操作审计日志导出详情列表
"""
operation_log_list = await PluginDao.get_plugin_operation_log_export_list(query_db, query_object)
return [PluginOperationLogBuilder.build_detail(operation_log) for operation_log in operation_log_list]
@classmethod
def export_plugin_operation_log_list_services(
cls,
operation_log_list: list[PluginOperationLogDetailModel],
operation_dict: dict[str, str] | None = None,
) -> bytes:
"""
导出插件批量操作审计日志。
:param operation_log_list: 插件批量操作审计日志导出详情列表
:param operation_dict: 插件操作类型字典
:return: 插件批量操作审计日志 Excel 二进制数据
"""
export_list = [
PluginOperationLogBuilder.build_export_row(operation_log, operation_dict)
for operation_log in operation_log_list
]
mapping_dict = {
'operationId': '日志编号',
'operation': '操作类型',
'pluginIds': '目标插件',
'dryRun': '是否预演',
'continueOnError': '失败后继续',
'status': '执行状态',
'summary': '执行汇总',
'remark': '备注',
'createTime': '创建时间',
}
return ExcelUtil.export_list2excel(export_list, mapping_dict)
@classmethod
async def retain_plugin_operation_log_services(
cls,
query_db: AsyncSession,
retention_model: PluginOperationLogRetentionModel,
) -> PluginOperationLogRetentionResultModel:
"""
按保留策略清理插件批量操作审计日志。
:param query_db: orm对象
:param retention_model: 插件批量操作审计日志保留策略模型
:return: 插件批量操作审计日志保留策略执行结果
"""
cutoff_time = datetime.now() - timedelta(days=retention_model.retention_days)
matched_count = await PluginDao.count_plugin_operation_logs_before(query_db, cutoff_time)
deleted_count = 0
if not retention_model.dry_run:
deleted_count = await PluginDao.delete_plugin_operation_logs_before(query_db, cutoff_time)
return PluginOperationLogRetentionResultModel(
retentionDays=retention_model.retention_days,
cutoffTime=cutoff_time,
matchedCount=matched_count,
deletedCount=deleted_count,
dryRun=retention_model.dry_run,
)
@classmethod
async def plugin_operation_log_detail_services(
cls,
query_db: AsyncSession,
operation_id: int,
) -> PluginOperationLogDetailModel | None:
"""
获取插件批量操作审计日志详情。
:param query_db: orm对象
:param operation_id: 操作日志ID
:return: 插件批量操作审计日志详情
"""
operation_log = await PluginDao.get_plugin_operation_log_by_id(query_db, operation_id)
if not operation_log:
return None
return PluginOperationLogBuilder.build_detail(CamelCaseUtil.transform_result(operation_log))
@classmethod
async def check_installed_menu_conflict_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
) -> list[PluginMenuConflictItem]:
"""
检查目标插件与数据库已存在菜单的权限冲突。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:return: 菜单冲突检查项列表
"""
conflicts = []
for menu in PluginMenuTree.flatten(discovered_plugin.manifest.frontend.menus):
if not menu.perms:
continue
sys_menu = await PluginDao.get_sys_menu_by_perms(query_db, menu.perms)
if not sys_menu:
continue
plugin_menu = await PluginDao.get_plugin_menu_by_menu_id(query_db, sys_menu.menu_id)
conflict_plugin_id = plugin_menu.plugin_id if plugin_menu else None
if conflict_plugin_id == discovered_plugin.manifest.id:
continue
conflict_label = conflict_plugin_id or 'core'
conflicts.append(
PluginMenuConflictItem(
kind='installed_permission',
plugin_id=discovered_plugin.manifest.id,
conflict_plugin_id=conflict_plugin_id,
value=menu.perms,
message=(
f'插件 {discovered_plugin.manifest.id} 权限 {menu.perms} '
f'与已存在菜单 {sys_menu.menu_id}({conflict_label})冲突'
),
)
)
return conflicts
@classmethod
async def mark_plugin_error_services(
cls, query_db: AsyncSession, plugin_id: str, error_message: str
) -> CrudResponseModel:
"""
标记插件错误。
:param query_db: orm对象
:param plugin_id: 插件ID
:param error_message: 错误信息
:return: 操作响应
"""
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
if not plugin:
return CrudResponseModel(is_success=False, message='插件不存在')
await PluginDao.update_plugin(
query_db,
{
'plugin_id': plugin_id,
'status': PluginStateTransitionTable.resolve_target(getattr(plugin, 'status', None), 'mark_error')
or 'error',
'last_error': error_message[:1000],
'update_time': datetime.now(),
},
)
await PluginMenuInstaller(query_db).set_plugin_menu_status(plugin_id, '1')
await PluginJobInstaller(query_db).pause_plugin_jobs(plugin_id)
return CrudResponseModel(is_success=True, message='插件状态已标记为异常')
@classmethod
async def recover_plugin_dependency_error_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
) -> CrudResponseModel:
"""
在启动依赖重新满足后恢复插件状态。
仅恢复由启动依赖检查写入的 error。恢复目标由已安装版本和当前源码版本
重新推导,未安装插件回到 discovered,已安装插件回到 installed 或
pending_upgrade;同时恢复启动前的启用意图并清除历史依赖错误。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:return: 操作响应
"""
plugin_id = discovered_plugin.manifest.id
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
if not plugin:
return CrudResponseModel(is_success=False, message='插件不存在')
last_error = getattr(plugin, 'last_error', None)
if (
getattr(plugin, 'status', None) != 'error'
or not isinstance(last_error, str)
or not last_error.startswith(PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX)
):
return CrudResponseModel(is_success=False, message='插件不是启动依赖检查异常状态')
installed_version = getattr(plugin, 'installed_version', None)
desired_enabled = PluginStateResolver.db_value_to_enabled(
getattr(plugin, 'enabled', None),
fallback=False,
)
target_status = PluginStateResolver.resolve(
PluginStateSnapshot(
source_version=discovered_plugin.manifest.version,
installed_version=installed_version,
enabled=desired_enabled,
current_status=None,
)
)
await PluginDao.update_plugin(
query_db,
{
'plugin_id': plugin_id,
'status': target_status,
'last_error': None,
'update_time': datetime.now(),
},
)
return CrudResponseModel(is_success=True, message=f'插件启动依赖已恢复,状态:{target_status}')
@classmethod
async def build_plugin_purge_plan_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
) -> PluginPurgePlan:
"""
构建插件物理清理计划。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:return: 插件物理清理计划
"""
plugin_id = discovered_plugin.manifest.id
menu_count = await PluginDao.count_plugin_menus(query_db, plugin_id)
config_count = await PluginDao.count_plugin_configs(query_db, plugin_id)
migration_count = await PluginDao.count_plugin_migrations(query_db, plugin_id)
job_count = await PluginJobRepository(query_db).count_jobs_by_name_prefix(f'{plugin_id}:')
return PluginPurgePlanner.build_plan(
discovered_plugin,
menu_count=menu_count,
config_count=config_count,
migration_count=migration_count,
job_count=job_count,
)
@classmethod
async def purge_plugin_services(
cls,
query_db: AsyncSession,
discovered_plugin: DiscoveredPlugin,
) -> PluginPurgePlan:
"""
清理插件平台元数据。
该方法只清理由平台拥有的插件记录、菜单关联、配置、migration 历史和插件任务;
插件业务数据需由运行时在调用本方法前通过 on_purge 钩子显式清理。
:param query_db: orm对象
:param discovered_plugin: 已发现插件对象
:return: 执行前构建的插件物理清理计划
"""
plugin_id = discovered_plugin.manifest.id
plan = await cls.build_plugin_purge_plan_services(query_db, discovered_plugin)
await cls._purge_plugin_metadata_by_id(query_db, plugin_id)
return plan
@classmethod
async def build_plugin_purge_plan_by_id_services(
cls,
query_db: AsyncSession,
plugin_id: str,
) -> PluginPurgePlan:
"""
为源码已缺失的插件按 ID 构建平台元数据清理计划。
:param query_db: orm对象
:param plugin_id: 插件ID
:return: 插件物理清理计划
"""
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
menu_count = await PluginDao.count_plugin_menus(query_db, plugin_id)
config_count = await PluginDao.count_plugin_configs(query_db, plugin_id)
migration_count = await PluginDao.count_plugin_migrations(query_db, plugin_id)
job_count = await PluginJobRepository(query_db).count_jobs_by_name_prefix(f'{plugin_id}:')
return PluginPurgePlanner.build_metadata_plan(
plugin_id,
state_count=1 if plugin else 0,
menu_count=menu_count,
config_count=config_count,
migration_count=migration_count,
job_count=job_count,
)
@classmethod
async def purge_plugin_metadata_by_id_services(
cls,
query_db: AsyncSession,
plugin_id: str,
) -> PluginPurgePlan:
"""
按插件 ID 清理平台拥有的孤儿元数据。
:param query_db: orm对象
:param plugin_id: 插件ID
:return: 执行前构建的插件物理清理计划
"""
plan = await cls.build_plugin_purge_plan_by_id_services(query_db, plugin_id)
await cls._purge_plugin_metadata_by_id(query_db, plugin_id)
return plan
@classmethod
async def _purge_plugin_metadata_by_id(cls, query_db: AsyncSession, plugin_id: str) -> None:
"""
删除平台能够按插件 ID 确认归属的元数据。
:param query_db: orm对象
:param plugin_id: 插件ID
:return: None
"""
plugin_menus = await PluginDao.get_plugin_menu_list(query_db, plugin_id)
menu_ids = [plugin_menu.menu_id for plugin_menu in plugin_menus]
plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id)
if plugin:
await cls.update_plugin_enabled_services(query_db, plugin_id, enabled=False)
await PluginDao.delete_plugin_menus(query_db, plugin_id)
await PluginDao.delete_sys_menus_by_ids(query_db, menu_ids)
await PluginDao.delete_plugin_configs(query_db, plugin_id)
await PluginDao.delete_plugin_migrations(query_db, plugin_id)
await PluginJobRepository(query_db).delete_jobs_by_name_prefix(f'{plugin_id}:')
await PluginDao.delete_plugin(query_db, plugin_id)
@classmethod
async def upsert_plugin_menu_services(
cls, query_db: AsyncSession, plugin_id: str, menu_id: int, menu_key: str
) -> PluginMenuModel:
"""
写入插件菜单关联。
:param query_db: orm对象
:param plugin_id: 插件ID
:param menu_id: 菜单ID
:param menu_key: 插件内菜单自然键
:return: 插件菜单关联对象
"""
existing_plugin_menu = await PluginDao.get_plugin_menu_by_key(query_db, plugin_id, menu_key)
plugin_menu_model = PluginMenuModel(pluginId=plugin_id, menuId=menu_id, menuKey=menu_key)
if existing_plugin_menu and existing_plugin_menu.menu_id != menu_id:
await PluginDao.update_plugin_menu_by_key(query_db, plugin_menu_model)
elif not existing_plugin_menu:
await PluginDao.add_plugin_menu(query_db, plugin_menu_model)
return plugin_menu_model
@classmethod
async def get_plugin_migration_services(
cls,
query_db: AsyncSession,
plugin_id: str,
migration_path: str,
) -> PluginMigrationModel | None:
"""
获取插件 migration 执行历史。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:return: 插件 migration 执行历史对象
"""
plugin_migration = await PluginDao.get_plugin_migration_by_path(query_db, plugin_id, migration_path)
if not plugin_migration:
return None
return PluginMigrationModel(**CamelCaseUtil.transform_result(plugin_migration))
@classmethod
async def get_plugin_migration_list_services(
cls,
query_db: AsyncSession,
plugin_id: str,
status: str | None = None,
) -> list[PluginMigrationModel]:
"""
获取插件 migration 执行历史列表。
:param query_db: orm对象
:param plugin_id: 插件ID
:param status: 执行状态
:return: 插件 migration 执行历史列表
"""
plugin_migrations = await PluginDao.get_plugin_migration_list(query_db, plugin_id, status)
return [PluginMigrationModel(**CamelCaseUtil.transform_result(item)) for item in plugin_migrations]
@classmethod
async def add_plugin_migration_services(
cls,
query_db: AsyncSession,
plugin_migration: PluginMigrationModel,
) -> PluginMigrationModel:
"""
新增插件 migration 执行历史。
:param query_db: orm对象
:param plugin_migration: 插件 migration 执行历史对象
:return: 插件 migration 执行历史对象
"""
await PluginDao.add_plugin_migration(query_db, plugin_migration)
return plugin_migration
@classmethod
async def mark_plugin_migration_status_services(
cls,
query_db: AsyncSession,
plugin_id: str,
migration_path: str,
status: str,
error_message: str | None = None,
) -> PluginMigrationModel | None:
"""
人工标记插件 migration 执行历史状态。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:param status: 执行状态
:param error_message: 失败错误信息
:return: 更新后的插件 migration 执行历史对象
"""
plugin_migration = await PluginDao.update_plugin_migration_status(
query_db,
plugin_id,
migration_path,
status,
error_message,
)
if not plugin_migration:
return None
return PluginMigrationModel(**CamelCaseUtil.transform_result(plugin_migration))
@staticmethod
def _build_plugin_model(
discovered_plugin: DiscoveredPlugin,
backend_root: Path,
frontend_root: Path | None,
existing_plugin: object | None,
) -> PluginModel:
"""
根据发现结果和数据库状态构建插件信息模型。
:param discovered_plugin: 已发现插件对象
:param backend_root: 后端插件根目录
:param frontend_root: 前端插件根目录
:param existing_plugin: 数据库中已有插件对象
:return: 插件信息模型
"""
manifest = discovered_plugin.manifest
capability = PluginRuntimeCapabilityResolver(
frontend_mode=PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_mode(),
backend_runtime_mode=PLUGIN_RUNTIME_ENVIRONMENT.get_backend_runtime_mode(),
).resolve(discovered_plugin)
current_enabled = getattr(existing_plugin, 'enabled', None)
enabled = current_enabled if current_enabled is not None else '0'
installed_version = getattr(existing_plugin, 'installed_version', None)
current_status = getattr(existing_plugin, 'status', None)
last_error = getattr(existing_plugin, 'last_error', None)
status = PluginService._resolve_status(manifest.version, installed_version, enabled, current_status)
frontend_path = frontend_root / manifest.frontend.plugin_id if frontend_root else None
frontend_menus = PluginMenuTree.flatten(manifest.frontend.menus)
return PluginModel(
pluginId=manifest.id,
pluginName=manifest.name,
version=manifest.version,
installedVersion=installed_version,
enabled=enabled,
status=status,
source='local',
backendPath=str(discovered_plugin.backend_path.relative_to(backend_root)),
frontendPath=str(frontend_path.relative_to(frontend_root)) if frontend_path and frontend_root else None,
lastError=last_error,
description=manifest.description,
updateTime=datetime.now(),
capability=capability.to_payload(),
metadata=manifest.metadata.model_dump(by_alias=True),
backend={
'module': manifest.backend.module,
'autoScanRouters': manifest.backend.routers.auto_scan,
'migrations': manifest.backend.migrations,
'seeds': manifest.backend.seeds,
'jobs': [job.model_dump(by_alias=True) for job in manifest.backend.jobs],
},
frontend={
'pluginId': manifest.frontend.plugin_id,
'basePath': manifest.frontend.base_path,
'viewsPath': manifest.frontend.views_path,
'apiPath': manifest.frontend.api_path,
'delivery': manifest.frontend.delivery.model_dump(by_alias=True),
'menus': [menu.model_dump(by_alias=True) for menu in frontend_menus],
},
permissions=[permission.model_dump(by_alias=True) for permission in manifest.permissions],
config=[config_item.model_dump(by_alias=True) for config_item in manifest.config.items],
dependencies={
'python': manifest.dependencies.python,
'npm': manifest.dependencies.npm,
'npmDev': manifest.dependencies.npm_dev,
},
pluginDependencies=[dependency.model_dump(by_alias=True) for dependency in manifest.dependencies.plugins],
)
@classmethod
def _build_orphan_plugin_model(cls, plugin: object) -> PluginModel:
"""
构建源码缺失但平台元数据仍存在的孤儿插件视图。
孤儿记录只能执行平台元数据物理清理,其他生命周期操作均依赖缺失的
manifest 和源码,因此通过 capability 明确阻断。
:param plugin: 数据库插件状态对象
:return: 孤儿插件信息模型
"""
model = PluginModel(**CamelCaseUtil.transform_result(plugin))
blocked_operations = sorted(STATE_CHANGE_OPERATIONS - {'purge'})
return model.model_copy(
update={
'source': 'orphan',
'capability': {
'pluginId': model.plugin_id,
'frontendMode': PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_mode(),
'backendRuntimeMode': PLUGIN_RUNTIME_ENVIRONMENT.get_backend_runtime_mode(),
'hasFrontendResources': False,
'frontendBuildRequired': False,
'frontendRuntimeManageable': False,
'backendRuntimeManageable': False,
'runtimeManageable': False,
'blockedOperations': blocked_operations,
'warnings': [cls.ORPHAN_PLUGIN_REASON],
'primaryReason': cls.ORPHAN_PLUGIN_REASON,
},
}
)
@staticmethod
def _resolve_status(
version: str,
installed_version: str | None,
enabled: str,
current_status: str | None = None,
) -> PluginStatus:
"""
解析插件状态。
:param version: 当前源码版本
:param installed_version: 已安装版本
:param enabled: 启停状态
:param current_status: 当前数据库状态
:return: 插件状态
"""
return PluginStateResolver.resolve(
PluginStateSnapshot(
source_version=version,
installed_version=installed_version,
enabled=PluginStateResolver.db_value_to_enabled(enabled, fallback=False),
current_status=current_status,
)
)
@staticmethod
def _filter_plugin_page_items(
plugin_items: list[dict[str, Any]],
query_object: PluginPageQueryModel,
) -> list[dict[str, Any]]:
"""
根据插件管理页面查询条件过滤插件列表。
:param plugin_items: 插件列表项
:param query_object: 插件分页查询对象
:return: 过滤后的插件列表项
"""
def contains(value: object, keyword: str | None) -> bool:
return keyword is None or keyword in str(value or '')
return [
item
for item in plugin_items
if contains(item.get('pluginId'), query_object.plugin_id)
and contains(item.get('pluginName'), query_object.plugin_name)
and (query_object.enabled is None or item.get('enabled') == query_object.enabled)
and (query_object.status is None or item.get('status') == query_object.status)
and (query_object.source is None or item.get('source') == query_object.source)
]
@staticmethod
def _get_discovered_plugin(backend_root: Path, plugin_id: str) -> DiscoveredPlugin | None:
"""
从本地插件目录获取指定插件发现结果。
:param backend_root: 后端插件根目录
:param plugin_id: 插件ID
:return: 已发现插件对象
"""
for discovered_plugin in PluginScanner(backend_root).discover():
if discovered_plugin.manifest.id == plugin_id:
return discovered_plugin
return None