From 23975442d8e2344aa68fd1352c04d93c9c6434ae Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:49:33 +0800 Subject: [PATCH] Update plugin dynamic import check (#1112) * Update plugin dynamic import check * Update cli dynamic import --- backend/app/admin/crud/crud_user.py | 19 +++--- backend/cli.py | 58 +++++++++--------- backend/core/conf.py | 1 + backend/main.py | 7 ++- backend/plugin/core.py | 92 ++++++++++++++++------------- backend/utils/dynamic_config.py | 32 ++++++---- backend/utils/dynamic_import.py | 19 ------ 7 files changed, 120 insertions(+), 108 deletions(-) diff --git a/backend/app/admin/crud/crud_user.py b/backend/app/admin/crud/crud_user.py index 4eb6a955..d88ba8b2 100644 --- a/backend/app/admin/crud/crud_user.py +++ b/backend/app/admin/crud/crud_user.py @@ -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( diff --git a/backend/cli.py b/backend/cli.py index f2dd5538..188b5743 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -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) diff --git a/backend/core/conf.py b/backend/core/conf.py index c5563eda..f66c24ff 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 323ed073..70edddc4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/plugin/core.py b/backend/plugin/core.py index 2a0ea38e..847bef82 100644 --- a/backend/plugin/core.py +++ b/backend/plugin/core.py @@ -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} 未启用,请联系系统管理员') diff --git a/backend/utils/dynamic_config.py b/backend/utils/dynamic_config.py index eefd861e..3c37fe58 100644 --- a/backend/utils/dynamic_config.py +++ b/backend/utils/dynamic_config.py @@ -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, diff --git a/backend/utils/dynamic_import.py b/backend/utils/dynamic_import.py index e8885091..33b48326 100644 --- a/backend/utils/dynamic_import.py +++ b/backend/utils/dynamic_import.py @@ -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: """ 获取模型对象