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

212 lines
7.7 KiB
Python

import importlib.util
import re
from pathlib import Path
from types import ModuleType
SQL_LINE_COMMENT_PATTERN = re.compile(r'^\s*--')
SQL_DELIMITER_PATTERN = re.compile(r'^\s*DELIMITER\b', re.IGNORECASE)
DATABASE_DIALECT_DIRS = {'mysql', 'postgresql'}
class PluginLifecycleScriptHelper:
"""
插件生命周期脚本通用工具。
migration 和 seed 都支持 Python/SQL 文件、数据库方言目录和插件根目录约束,
这里集中这些纯辅助逻辑,避免两个 runner 各自维护一套实现。
"""
@staticmethod
def split_sql_statements(sql_content: str) -> list[str]:
"""
将 SQL 文件内容拆分为语句列表。
:param sql_content: SQL 文件内容
:return: SQL 语句列表
"""
for line in sql_content.splitlines():
if SQL_DELIMITER_PATTERN.match(line):
raise RuntimeError('插件 SQL 脚本暂不支持 DELIMITER,请改用 Python migration 或拆分为简单 SQL 文件')
statement_chunks = []
current_chars = []
quote_char = None
line_has_content = False
index = 0
while index < len(sql_content):
char = sql_content[index]
next_char = sql_content[index + 1] if index + 1 < len(sql_content) else ''
if quote_char is None and char == '-' and next_char == '-' and not line_has_content:
index = PluginLifecycleScriptHelper._skip_sql_line_comment(sql_content, index + 2)
line_has_content = False
continue
index, quote_char, statement_complete = PluginLifecycleScriptHelper._consume_sql_character(
sql_content,
index,
current_chars,
quote_char,
)
if statement_complete:
statement_chunks.append(''.join(current_chars))
current_chars = []
line_has_content = PluginLifecycleScriptHelper._update_sql_line_content_state(char, line_has_content)
index += 1
if current_chars:
statement_chunks.append(''.join(current_chars))
return [statement.strip().removesuffix(';').strip() for statement in statement_chunks if statement.strip()]
@staticmethod
def _consume_sql_character(
sql_content: str,
index: int,
current_chars: list[str],
quote_char: str | None,
) -> tuple[int, str | None, bool]:
"""
消费一个 SQL 字符,并返回新的索引、引号状态和语句是否结束。
:param sql_content: SQL 文件内容
:param index: 当前字符索引
:param current_chars: 当前语句字符缓存
:param quote_char: 当前引号状态
:return: 新索引、新引号状态、语句是否结束
"""
char = sql_content[index]
next_char = sql_content[index + 1] if index + 1 < len(sql_content) else ''
if quote_char is None and char in {"'", '"', '`'}:
current_chars.append(char)
return index, char, False
if quote_char == char:
current_chars.append(char)
if char == "'" and next_char == "'":
current_chars.append(next_char)
return index + 1, quote_char, False
return index, None, False
if quote_char is not None and char == '\\' and next_char:
current_chars.extend([char, next_char])
return index + 1, quote_char, False
if quote_char is None and char == ';':
return index, quote_char, True
current_chars.append(char)
return index, quote_char, False
@staticmethod
def _update_sql_line_content_state(char: str, line_has_content: bool) -> bool:
"""
更新当前行是否已有非空白内容。
:param char: 当前字符
:param line_has_content: 当前行是否已有内容
:return: 更新后的当前行内容状态
"""
if char == '\n':
return False
if not char.isspace():
return True
return line_has_content
@staticmethod
def _skip_sql_line_comment(sql_content: str, index: int) -> int:
"""
跳过 SQL 行注释。
:param sql_content: SQL 文件内容
:param index: 注释起始位置之后的索引
:return: 下一段内容的索引
"""
while index < len(sql_content) and sql_content[index] != '\n':
index += 1
return index + 1 if index < len(sql_content) else index
@staticmethod
def filter_current_database_paths(
script_paths: list[str],
*,
root_dir: str,
database_type: str,
) -> list[str]:
"""
过滤当前数据库方言不匹配的脚本。
:param script_paths: 脚本相对路径列表
:param root_dir: 方言目录父级,例如 migrations 或 seeds
:param database_type: 当前数据库类型
:return: 当前数据库需要执行的脚本列表
"""
filtered_paths = []
for script_path in script_paths:
path_parts = Path(script_path).parts
dialect_dir = path_parts[1] if len(path_parts) > 1 and path_parts[0] == root_dir else None
if dialect_dir in DATABASE_DIALECT_DIRS and dialect_dir != database_type:
continue
filtered_paths.append(script_path)
return filtered_paths
@staticmethod
def resolve_file(
plugin_root: Path,
script_path: str,
*,
supported_suffixes: set[str],
label: str,
) -> Path:
"""
解析生命周期脚本文件绝对路径。
:param plugin_root: 插件后端根目录
:param script_path: 脚本相对插件根目录路径
:param supported_suffixes: 支持的文件后缀
:param label: 脚本类型展示名
:return: 脚本文件绝对路径
"""
script_file = (plugin_root / script_path).resolve()
resolved_plugin_root = plugin_root.resolve()
if resolved_plugin_root not in script_file.parents:
raise RuntimeError(f'插件 {label} 路径不能越过插件根目录:{script_path}')
if script_file.suffix not in supported_suffixes:
raise RuntimeError(f'插件 {label} 仅支持 Python 或 SQL 文件:{script_path}')
if not script_file.is_file():
raise RuntimeError(f'插件 {label} 文件不存在:{script_path}')
return script_file
@staticmethod
def load_module(module_name: str, script_file: Path, *, label: str) -> ModuleType:
"""
加载生命周期 Python 脚本模块。
:param module_name: 模块名
:param script_file: 脚本文件绝对路径
:param label: 脚本类型展示名
:return: Python 模块
"""
module_spec = importlib.util.spec_from_file_location(module_name, script_file)
if module_spec is None or module_spec.loader is None:
raise RuntimeError(f'插件 {label} 模块加载失败:{script_file}')
script_module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(script_module)
return script_module
@staticmethod
def build_module_name(plugin_id: str, plugin_root: Path, script_file: Path) -> str:
"""
构建生命周期脚本模块名。
:param plugin_id: 插件ID
:param plugin_root: 插件后端根目录
:param script_file: 脚本文件绝对路径
:return: 模块名
"""
relative_path = script_file.relative_to(plugin_root).with_suffix('')
module_suffix = '.'.join(relative_path.parts)
return f'plugins.{plugin_id}.{module_suffix}'