Update plugin dynamic import check (#1112)

* Update plugin dynamic import check

* Update cli dynamic import
This commit is contained in:
Wu Clan
2026-03-19 11:49:33 +08:00
committed by GitHub
parent 577a13b271
commit 23975442d8
7 changed files with 120 additions and 108 deletions
+10 -9
View File
@@ -25,7 +25,8 @@ from backend.app.admin.schema.user import (
UpdateUserParam,
)
from backend.app.admin.utils.password_security import get_hash_password
from backend.utils.dynamic_import import import_module_cached
from backend.common.exception import errors
from backend.plugin.core import check_plugin_installed
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
@@ -298,17 +299,17 @@ class CRUDUser(CRUDPlus[User]):
:param user_id: 用户 ID
:return:
"""
if check_plugin_installed('oauth2'):
try:
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
await user_social_dao.delete_by_user_id(db, user_id)
except ImportError:
raise errors.ServerError(msg='OAuth2 插件用法导入失败,请联系系统管理员')
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
try:
user_social = import_module_cached('backend.plugin.oauth2.crud.crud_user_social')
user_social_dao = user_social.user_social_dao
except (ImportError, AttributeError):
pass
else:
await user_social_dao.delete_by_user_id(db, user_id)
return await self.delete_model(db, user_id)
async def get_join(
+30 -28
View File
@@ -43,7 +43,7 @@ from backend.database.db import (
create_database_url,
)
from backend.database.redis import RedisCli, redis_client
from backend.plugin.core import get_plugin_destroy_sql, get_plugin_sql, get_plugins
from backend.plugin.core import build_sql_filename, get_plugin_destroy_sql, get_plugin_sql, get_plugins
from backend.plugin.installer import install_git_plugin, install_zip_plugin, zip_plugin
from backend.plugin.installer import remove_plugin as _remove_plugin
from backend.plugin.requirements import uninstall_requirements_async
@@ -346,7 +346,7 @@ def run_celery_flower(port: int, basic_auth: str) -> None:
pass
async def install_plugin(
async def install_plugin( # noqa: C901
path: str,
repo_url: str,
no_sql: bool, # noqa: FBT001
@@ -373,6 +373,16 @@ async def install_plugin(
console.tip(f'插件 {plugin_name} 安装成功')
console.note(f'正在同步插件 {plugin_name} 数据库表...')
try:
import_module_cached(f'backend.plugin.{plugin_name}.model')
except ModuleNotFoundError:
pass
else:
async with async_db_session.begin() as db:
conn = await db.connection()
await conn.run_sync(MappedBase.metadata.create_all)
if not no_sql:
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
if sql_file:
@@ -445,23 +455,21 @@ async def remove_plugin(plugin: str | None, *, no_sql: bool = False) -> None: #
async def get_sql_scripts() -> list[str]:
"""获取所有待执行的 SQL 脚本路径列表"""
sql_scripts = []
sql_scripts: list[str] = []
db_script_dir = MYSQL_SCRIPT_DIR if DataBaseType.mysql == settings.DATABASE_TYPE else POSTGRESQL_SCRIPT_DIR
main_sql_file = (
db_script_dir / 'init_test_data.sql'
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
else db_script_dir / 'init_snowflake_test_data.sql'
main_sql_file = db_script_dir / build_sql_filename(
'init',
settings.DATABASE_PK_MODE,
suffix='test_data',
)
main_sql_path = anyio.Path(main_sql_file)
if await main_sql_path.exists():
if await anyio.Path(main_sql_file).exists():
sql_scripts.append(str(main_sql_file))
plugins = get_plugins()
for plugin in plugins:
for plugin in get_plugins():
plugin_sql = await get_plugin_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
if plugin_sql:
sql_scripts.append(str(plugin_sql))
sql_scripts.append(plugin_sql)
return sql_scripts
@@ -500,8 +508,11 @@ async def import_table(
if settings.ENVIRONMENT != 'dev':
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
try:
from backend.plugin.code_generator.schema.gen import ImportParam
from backend.plugin.code_generator.service.gen_service import gen_service
except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try:
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
@@ -518,8 +529,11 @@ async def generate(*, preview: bool = False) -> None:
if settings.ENVIRONMENT != 'dev':
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.gen_service import gen_service
try:
from backend.plugin.code_generator.service.business_service import gen_business_service
from backend.plugin.code_generator.service.gen_service import gen_service
except ImportError:
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
try:
ids = []
@@ -753,12 +767,6 @@ class Import:
cappa.Arg(short='tn', help='数据库表名'),
]
def __post_init__(self) -> None:
try:
import_module_cached('backend.plugin.code_generator')
except ImportError:
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
async def __call__(self) -> None:
await import_table(self.app, self.table_schema, self.table_name)
@@ -772,12 +780,6 @@ class CodeGenerator:
]
subcmd: cappa.Subcommands[Import | None] = None
def __post_init__(self) -> None:
try:
import_module_cached('backend.plugin.code_generator')
except ImportError:
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
async def __call__(self) -> None:
await generate(preview=self.preview)
+1
View File
@@ -247,6 +247,7 @@ class Settings(BaseSettings):
OPERA_LOG_QUEUE_TIMEOUT: int = 60 # 1 分钟
# Plugin 配置
PLUGIN_REQUIRED: list[str] = ['dict']
PLUGIN_PIP_CHINA: bool = True
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
PLUGIN_PIP_MAX_RETRY: int = 3
+6 -1
View File
@@ -2,12 +2,17 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.text import Text
from backend.core.registrar import register_app
from backend.plugin.core import get_plugins
from backend.plugin.core import check_required_plugins, get_plugins
from backend.plugin.requirements import install_requirements
from backend.utils.console import console
from backend.utils.timezone import timezone
_log_prefix = f'{timezone.to_str(timezone.now(), "%Y-%m-%d %H:%M:%S.%M0")} | {"INFO": <8} | - | '
console.print(Text(f'{_log_prefix}检查必需插件...', style='bold cyan'))
check_required_plugins()
console.print(Text(f'{_log_prefix}检测插件依赖...', style='bold cyan'))
_plugins = get_plugins()
+52 -40
View File
@@ -22,6 +22,27 @@ from backend.utils.async_helper import run_await
from backend.utils.dynamic_import import get_model_objects, import_module_cached
def check_plugin_installed(plugin_name: str) -> bool:
"""
检查插件是否已安装
:param plugin_name: 插件名称
:return:
"""
return (PLUGIN_DIR / plugin_name / '__init__.py').exists()
def check_required_plugins() -> None:
"""检查必需插件"""
required_plugins = list(settings.PLUGIN_REQUIRED)
if not settings.RBAC_ROLE_MENU_MODE and 'casbin_rbac' not in required_plugins:
required_plugins.append('casbin_rbac')
missing_plugins = [name for name in required_plugins if not check_plugin_installed(name)]
if missing_plugins:
raise PluginInjectError(f'当前系统缺少以下插件: {", ".join(missing_plugins)},请先安装对应插件')
@lru_cache(maxsize=128)
def get_plugins() -> tuple[str, ...]:
"""获取插件列表"""
@@ -53,6 +74,20 @@ def get_plugin_models() -> list[object]:
return objs
def build_sql_filename(
prefix: str,
pk_type: PrimaryKeyType,
*,
suffix: str | None = None,
) -> str:
parts = [prefix]
if pk_type == PrimaryKeyType.snowflake:
parts.append('snowflake')
if suffix:
parts.append(suffix)
return f'{"_".join(parts)}.sql'
async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
"""
获取插件 SQL 脚本
@@ -62,24 +97,10 @@ async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKey
:param pk_type: 主键类型
:return:
"""
if db_type == DataBaseType.mysql:
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
sql_file = (
mysql_dir / 'init.sql' if pk_type == PrimaryKeyType.autoincrement else mysql_dir / 'init_snowflake.sql'
)
else:
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
sql_file = (
postgresql_dir / 'init.sql'
if pk_type == PrimaryKeyType.autoincrement
else postgresql_dir / 'init_snowflake.sql'
)
path = anyio.Path(sql_file)
if not await path.exists():
return None
return sql_file
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
default_filename = build_sql_filename('init', pk_type)
default_sql_file = sql_dir / default_filename
return str(default_sql_file) if await anyio.Path(default_sql_file).exists() else None
async def get_plugin_destroy_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
@@ -91,26 +112,9 @@ async def get_plugin_destroy_sql(plugin: str, db_type: DataBaseType, pk_type: Pr
:param pk_type: 主键类型
:return:
"""
if db_type == DataBaseType.mysql:
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
sql_file = (
mysql_dir / 'destroy.sql'
if pk_type == PrimaryKeyType.autoincrement
else mysql_dir / 'destroy_snowflake.sql'
)
else:
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
sql_file = (
postgresql_dir / 'destroy.sql'
if pk_type == PrimaryKeyType.autoincrement
else postgresql_dir / 'destroy_snowflake.sql'
)
path = anyio.Path(sql_file)
if not await path.exists():
return None
return sql_file
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
sql_file = sql_dir / build_sql_filename('destroy', pk_type)
return str(sql_file) if await anyio.Path(sql_file).exists() else None
def load_plugin_config(plugin: str) -> dict[str, Any]:
@@ -158,7 +162,10 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
data['plugin']['name'] = plugin
plugin_cache_info = run_await(current_redis_client.get)(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
if plugin_cache_info:
data['plugin']['enable'] = json.loads(plugin_cache_info)['plugin']['enable']
try:
data['plugin']['enable'] = json.loads(plugin_cache_info)['plugin']['enable']
except Exception:
data['plugin']['enable'] = str(StatusType.enable.value)
else:
data['plugin']['enable'] = str(StatusType.enable.value)
@@ -306,5 +313,10 @@ class PluginStatusChecker:
log.error('插件状态未初始化或丢失,需重启服务自动修复')
raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员')
if not int(json.loads(plugin_info)['plugin']['enable']):
try:
is_enabled = int(json.loads(plugin_info)['plugin']['enable'])
except Exception:
is_enabled = 0
if not is_enabled:
raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')
+21 -11
View File
@@ -1,24 +1,25 @@
from collections.abc import Callable
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import AsyncSession
from backend.core.conf import settings
from backend.database.db import async_engine
from backend.plugin.config.enums import ConfigType
from backend.plugin.config.service.config_service import config_service
from backend.plugin.core import check_plugin_installed
from backend.utils.serializers import select_list_serialize
_sys_config_table_exists: bool | None = None
_config_plugin_installed = check_plugin_installed('config')
async def check_sys_config_table_exists() -> bool:
"""检查 sys_config 表是否存在"""
global _sys_config_table_exists
if _sys_config_table_exists is None:
async with async_engine.connect() as conn:
_sys_config_table_exists = await conn.run_sync(lambda c: inspect(c).has_table('sys_config', schema=None))
return _sys_config_table_exists
if _config_plugin_installed:
try:
from backend.plugin.config.enums import ConfigType
from backend.plugin.config.service.config_service import config_service
except ImportError:
raise ImportError('参数配置插件用法导入失败,请联系系统管理员')
else:
ConfigType = None
config_service = None
def _to_bool(value: str) -> bool:
@@ -41,7 +42,7 @@ async def _load_config(
:param status_key: 状态键
:return:
"""
if not await check_sys_config_table_exists():
if not _config_plugin_installed or config_service is None:
return
dynamic_config = await config_service.get_all(db=db, type=config_type)
@@ -65,6 +66,9 @@ async def load_user_security_config(db: AsyncSession) -> None:
:param db: 数据库会话
:return:
"""
if ConfigType is None:
return
mapping = {
'USER_LOCK_THRESHOLD': int,
'USER_LOCK_SECONDS': int,
@@ -85,6 +89,9 @@ async def load_login_config(db: AsyncSession) -> None:
:param db: 数据库会话
:return:
"""
if ConfigType is None:
return
mapping = {
'LOGIN_CAPTCHA_ENABLED': _to_bool,
}
@@ -98,6 +105,9 @@ async def load_email_config(db: AsyncSession) -> None:
:param db: 数据库会话
:return:
"""
if ConfigType is None:
return
mapping = {
'EMAIL_HOST': str,
'EMAIL_PORT': int,
-19
View File
@@ -7,9 +7,6 @@ from typing import Any, TypeVar
import sqlalchemy as sa
from backend.common.exception import errors
from backend.common.log import log
T = TypeVar('T')
@@ -24,22 +21,6 @@ def import_module_cached(module_path: str) -> Any:
return importlib.import_module(module_path)
def dynamic_import_data_model(module_path: str) -> type[T]:
"""
动态导入数据模型
:param module_path: 模块路径,格式为 'module_path.class_name'
:return:
"""
try:
module_path, class_name = module_path.rsplit('.', 1)
module = import_module_cached(module_path)
return getattr(module, class_name)
except Exception as e:
log.error(f'动态导入数据模型失败:{e}')
raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员')
def get_model_objects(module_path: str) -> list[object] | None:
"""
获取模型对象