mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 21:15:13 +00:00
Update plugin dynamic import check (#1112)
* Update plugin dynamic import check * Update cli dynamic import
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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} 未启用,请联系系统管理员')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
获取模型对象
|
||||
|
||||
Reference in New Issue
Block a user