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

602 lines
20 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
import hashlib
import inspect
import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from sqlalchemy import text
from config.env import DataBaseConfig
from plugins.core.discovery.scanner import DiscoveredPlugin
from plugins.core.lifecycle.script import PluginLifecycleScriptHelper
SUPPORTED_MIGRATION_SUFFIXES = {'.py', '.sql'}
MIGRATION_ERROR_MESSAGE_MAX_LENGTH = 1000
class PluginMigrationError(RuntimeError):
"""
插件 migration 可恢复错误。
"""
def __init__(
self,
message: str,
*,
migration_path: str,
status: str,
recovery_suggestion: str,
) -> None:
"""
初始化插件 migration 错误。
:param message: 错误消息
:param migration_path: migration 相对插件根目录路径
:param status: 当前 migration 状态
:param recovery_suggestion: 恢复建议
"""
super().__init__(message)
self.migration_path = migration_path
self.status = status
self.recovery_suggestion = recovery_suggestion
def to_recovery_payload(self) -> dict[str, object]:
"""
转换为恢复建议负载。
:return: migration 恢复建议负载
"""
return {
'migrationPath': self.migration_path,
'status': self.status,
'suggestion': self.recovery_suggestion,
}
@dataclass(frozen=True)
class PluginMigrationHistoryRecord:
"""
插件 migration 历史记录。
:param checksum: migration 内容校验值
:param status: 执行状态
:param error_message: 失败错误信息
"""
checksum: str
status: str = 'success'
error_message: str | None = None
@dataclass(frozen=True)
class PluginMigrationResult:
"""
插件 migration 执行结果。
:param migration_path: migration 相对插件根目录路径
:param module_name: migration 模块名
:param statement_count: SQL 语句数量
:param checksum: migration 内容校验值
:param skipped: 是否跳过执行
:param status: 执行状态
:param duration_ms: 执行耗时,单位毫秒
:param recovery_suggestion: 恢复建议
"""
migration_path: str
module_name: str
statement_count: int = 0
checksum: str | None = None
skipped: bool = False
status: str = 'success'
duration_ms: int = 0
recovery_suggestion: str | None = None
class PluginMigrationHistoryStore:
"""
插件 migration 历史存储接口。
"""
async def get_record(
self,
query_db: Any,
plugin_id: str,
migration_path: str,
) -> PluginMigrationHistoryRecord | None:
"""
获取 migration 执行历史。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:return: migration 执行历史,不存在时返回 None
"""
raise NotImplementedError
async def get_checksum(self, query_db: Any, plugin_id: str, migration_path: str) -> str | None:
"""
获取已执行 migration 的内容校验值。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:return: 内容校验值,不存在时返回 None
"""
record = await self.get_record(query_db, plugin_id, migration_path)
if not record or record.status != 'success':
return None
return record.checksum
async def record_running(
self,
query_db: Any,
plugin_id: str,
migration_path: str,
checksum: str,
version: str,
statement_count: int,
) -> None:
"""
记录 migration 开始执行。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:param checksum: 内容校验值
:param version: 执行时插件版本
:param statement_count: SQL 语句数量
:return: None
"""
raise NotImplementedError
async def record_success(
self,
query_db: Any,
plugin_id: str,
migration_path: str,
checksum: str,
version: str,
statement_count: int,
) -> None:
"""
记录 migration 成功执行历史。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:param checksum: 内容校验值
:param version: 执行时插件版本
:param statement_count: SQL 语句数量
:return: None
"""
raise NotImplementedError
async def record_failure(
self,
query_db: Any,
plugin_id: str,
migration_path: str,
checksum: str,
version: str,
statement_count: int,
error_message: str,
) -> None:
"""
记录 migration 执行失败历史。
:param query_db: orm对象
:param plugin_id: 插件ID
:param migration_path: migration 相对路径
:param checksum: 内容校验值
:param version: 执行时插件版本
:param statement_count: 已解析 SQL 语句数量
:param error_message: 失败错误信息
:return: None
"""
raise NotImplementedError
class PluginMigrationRunner:
"""
插件 migration 运行器。
使用 Command Runner 模式按 manifest 声明顺序执行插件结构迁移脚本。
Python migration 模块需要暴露 `run(query_db)` 函数,SQL migration 会按分号拆分并逐条执行。
"""
def __init__(
self,
discovered_plugin: DiscoveredPlugin,
history_store: PluginMigrationHistoryStore | None = None,
*,
manage_execution_transaction: bool = False,
) -> None:
"""
初始化插件 migration 运行器。
:param discovered_plugin: 已发现插件对象
:param history_store: migration 执行历史存储
:param manage_execution_transaction: 是否由 runner 提交或回滚执行 session
:return: None
"""
self.discovered_plugin = discovered_plugin
self.history_store = history_store
self.manage_execution_transaction = manage_execution_transaction
async def run(self, query_db: Any) -> list[PluginMigrationResult]:
"""
执行插件清单声明的 migration。
:param query_db: orm对象
:return: migration 执行结果列表
"""
return [
await self._run_migration(migration_path, query_db)
for migration_path in self._filter_current_database_migrations(
self.discovered_plugin.manifest.backend.migrations
)
]
async def _run_migration(self, migration_path: str, query_db: Any) -> PluginMigrationResult:
"""
执行单个 migration。
:param migration_path: migration 相对插件根目录路径
:param query_db: orm对象
:return: migration 执行结果
"""
migration_file = self._resolve_migration_file(migration_path)
checksum = await asyncio.to_thread(self._calculate_checksum, migration_file)
existing_record = await self._get_existing_record(query_db, migration_path)
if existing_record:
if existing_record.status == 'success':
if existing_record.checksum != checksum:
raise PluginMigrationError(
f'插件 migration 已执行但内容校验值变化:{migration_path},请新增 migration 文件而不是修改历史文件',
migration_path=migration_path,
status='success',
recovery_suggestion='请恢复原 migration 文件内容,或新增一个后续 migration 文件承载变更。',
)
return PluginMigrationResult(
migration_path=migration_path,
module_name=self._build_migration_module_name(migration_file),
checksum=checksum,
skipped=True,
status='success',
)
if existing_record.status == 'running':
raise PluginMigrationError(
f'插件 migration 上次执行仍处于 running 状态:{migration_path},'
'请确认数据库结构状态后手动处理迁移历史',
migration_path=migration_path,
status='running',
recovery_suggestion=(
'请检查数据库结构是否已应用;若已成功应用,执行 mark-success;'
'若未完成,执行 mark-failed 后修复并重试。'
),
)
if existing_record.status != 'failed':
raise PluginMigrationError(
f'插件 migration 历史状态不支持自动执行:{migration_path},状态:{existing_record.status}',
migration_path=migration_path,
status=existing_record.status,
recovery_suggestion='请人工确认 migration 历史状态,并通过恢复命令标记为 success 或 failed。',
)
started_at = time.perf_counter()
await self._record_running(query_db, migration_path, checksum)
try:
if migration_file.suffix == '.sql':
result = await self._run_sql_migration(migration_path, migration_file, query_db, checksum)
else:
result = await self._run_python_migration(migration_path, migration_file, query_db, checksum)
await self._commit_execution_transaction(query_db)
except Exception as exc:
with suppress(Exception):
await self._rollback_execution_transaction(query_db)
await self._record_failure(query_db, migration_path, checksum, str(exc))
await self._commit_execution_transaction(query_db)
raise PluginMigrationError(
f'插件 migration 执行失败:{migration_path},{exc}',
migration_path=migration_path,
status='failed',
recovery_suggestion='请修复 migration 幂等性或数据库结构问题后重试;必要时人工确认后标记成功。',
) from exc
duration_ms = max(int((time.perf_counter() - started_at) * 1000), 0)
result = PluginMigrationResult(
migration_path=result.migration_path,
module_name=result.module_name,
statement_count=result.statement_count,
checksum=result.checksum,
skipped=result.skipped,
status='success',
duration_ms=duration_ms,
)
try:
await self._record_success(query_db, result)
except Exception as exc:
raise PluginMigrationError(
f'插件 migration 已执行,但成功历史记录失败:{migration_path},{exc}',
migration_path=migration_path,
status='running',
recovery_suggestion=(
'请检查数据库结构是否已应用;若已成功应用,执行 mark-success;'
'若未完成,执行 mark-failed 后修复并重试。'
),
) from exc
return result
async def _commit_execution_transaction(self, query_db: Any) -> None:
"""
提交 migration 执行事务。
:param query_db: migration 执行 session
:return: None
"""
if not self.manage_execution_transaction:
return
await self._call_execution_transaction_method(query_db, 'commit')
async def _rollback_execution_transaction(self, query_db: Any) -> None:
"""
回滚 migration 执行事务。
:param query_db: migration 执行 session
:return: None
"""
if not self.manage_execution_transaction:
return
await self._call_execution_transaction_method(query_db, 'rollback')
async def _call_execution_transaction_method(self, query_db: Any, method_name: str) -> None:
"""
调用 migration 执行 session 的事务方法。
:param query_db: migration 执行 session
:param method_name: 事务方法名
:return: None
"""
method = getattr(query_db, method_name, None)
if not callable(method):
raise RuntimeError(f'migration 执行 session 缺少 {method_name} 方法')
result = method()
if inspect.isawaitable(result):
await result
async def _run_python_migration(
self,
migration_path: str,
migration_file: Path,
query_db: Any,
checksum: str,
) -> PluginMigrationResult:
"""
执行 Python migration。
:param migration_path: migration 相对插件根目录路径
:param migration_file: migration 文件绝对路径
:param query_db: orm对象
:param checksum: migration 内容校验值
:return: migration 执行结果
"""
migration_module = self._load_migration_module(migration_file)
migration_runner = getattr(migration_module, 'run', None)
if not callable(migration_runner):
raise RuntimeError(f'插件 migration 必须暴露 run(query_db) 函数:{migration_path}')
result = migration_runner(query_db)
if inspect.isawaitable(result):
await result
return PluginMigrationResult(
migration_path=migration_path,
module_name=migration_module.__name__,
checksum=checksum,
)
async def _run_sql_migration(
self,
migration_path: str,
migration_file: Path,
query_db: Any,
checksum: str,
) -> PluginMigrationResult:
"""
执行 SQL migration。
:param migration_path: migration 相对插件根目录路径
:param migration_file: migration 文件绝对路径
:param query_db: orm对象
:param checksum: migration 内容校验值
:return: migration 执行结果
"""
statements = await asyncio.to_thread(self._load_sql_statements, migration_file)
for statement in statements:
await query_db.execute(text(statement))
return PluginMigrationResult(
migration_path=migration_path,
module_name=self._build_migration_module_name(migration_file),
statement_count=len(statements),
checksum=checksum,
)
async def _get_existing_record(self, query_db: Any, migration_path: str) -> PluginMigrationHistoryRecord | None:
"""
获取 migration 已有执行历史。
:param query_db: orm对象
:param migration_path: migration 相对路径
:return: migration 执行历史
"""
if not self.history_store:
return None
return await self.history_store.get_record(query_db, self.discovered_plugin.manifest.id, migration_path)
async def _record_running(self, query_db: Any, migration_path: str, checksum: str) -> None:
"""
记录 migration 开始执行。
:param query_db: orm对象
:param migration_path: migration 相对路径
:param checksum: migration 内容校验值
:return: None
"""
if not self.history_store:
return
await self.history_store.record_running(
query_db,
self.discovered_plugin.manifest.id,
migration_path,
checksum,
self.discovered_plugin.manifest.version,
self._get_statement_count(migration_path),
)
async def _record_success(self, query_db: Any, result: PluginMigrationResult) -> None:
"""
记录 migration 成功执行历史。
:param query_db: orm对象
:param result: migration 执行结果
:return: None
"""
if not self.history_store or not result.checksum:
return
await self.history_store.record_success(
query_db,
self.discovered_plugin.manifest.id,
result.migration_path,
result.checksum,
self.discovered_plugin.manifest.version,
result.statement_count,
)
async def _record_failure(
self,
query_db: Any,
migration_path: str,
checksum: str,
error_message: str,
) -> None:
"""
记录 migration 失败历史。
:param query_db: orm对象
:param migration_path: migration 相对路径
:param checksum: migration 内容校验值
:param error_message: 失败错误信息
:return: None
"""
if not self.history_store:
return
try:
statement_count = self._get_statement_count(migration_path)
except Exception:
statement_count = 0
await self.history_store.record_failure(
query_db,
self.discovered_plugin.manifest.id,
migration_path,
checksum,
self.discovered_plugin.manifest.version,
statement_count,
error_message[:MIGRATION_ERROR_MESSAGE_MAX_LENGTH],
)
def _get_statement_count(self, migration_path: str) -> int:
"""
获取 SQL migration 语句数量。
:param migration_path: migration 相对路径
:return: SQL 语句数量
"""
migration_file = self._resolve_migration_file(migration_path)
if migration_file.suffix != '.sql':
return 0
return len(self._load_sql_statements(migration_file))
def _load_sql_statements(self, migration_file: Path) -> list[str]:
"""
加载 SQL migration 语句列表。
:param migration_file: migration 文件绝对路径
:return: SQL 语句列表
"""
return PluginLifecycleScriptHelper.split_sql_statements(migration_file.read_text(encoding='utf-8'))
def _resolve_migration_file(self, migration_path: str) -> Path:
"""
解析 migration 文件绝对路径。
:param migration_path: migration 相对插件根目录路径
:return: migration 文件绝对路径
"""
return PluginLifecycleScriptHelper.resolve_file(
self.discovered_plugin.backend_path,
migration_path,
supported_suffixes=SUPPORTED_MIGRATION_SUFFIXES,
label='migration',
)
@classmethod
def _filter_current_database_migrations(cls, migration_paths: list[str]) -> list[str]:
"""
过滤当前数据库方言不匹配的 migration。
:param migration_paths: migration 相对路径列表
:return: 当前数据库需要执行的 migration 列表
"""
return PluginLifecycleScriptHelper.filter_current_database_paths(
migration_paths,
root_dir='migrations',
database_type=DataBaseConfig.db_type,
)
def _load_migration_module(self, migration_file: Path) -> Any:
"""
加载 migration Python 模块。
:param migration_file: migration 文件绝对路径
:return: migration 模块
"""
module_name = self._build_migration_module_name(migration_file)
return PluginLifecycleScriptHelper.load_module(module_name, migration_file, label='migration')
@staticmethod
def _calculate_checksum(migration_file: Path) -> str:
"""
计算 migration 文件内容校验值。
:param migration_file: migration 文件绝对路径
:return: SHA256 内容校验值
"""
return hashlib.sha256(migration_file.read_bytes()).hexdigest()
def _build_migration_module_name(self, migration_file: Path) -> str:
"""
构建 migration 模块名。
:param migration_file: migration 文件绝对路径
:return: migration 模块名
"""
return PluginLifecycleScriptHelper.build_module_name(
self.discovered_plugin.manifest.id,
self.discovered_plugin.backend_path,
migration_file,
)