diff --git a/backend/__init__.py b/backend/__init__.py index dc92c7e6..65498b61 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from backend.utils.import_parse import get_all_models +from backend.utils.dynamic_import import get_all_models # import all models for auto create db tables for cls in get_all_models(): diff --git a/backend/app/admin/crud/crud_user.py b/backend/app/admin/crud/crud_user.py index 6d10bfc5..65d7c6c7 100644 --- a/backend/app/admin/crud/crud_user.py +++ b/backend/app/admin/crud/crud_user.py @@ -25,7 +25,7 @@ from backend.app.admin.schema.user import ( UpdateUserParam, ) from backend.app.admin.utils.password_security import get_hash_password -from backend.utils.import_parse import import_module_cached +from backend.utils.dynamic_import import import_module_cached from backend.utils.serializers import select_join_serialize from backend.utils.timezone import timezone diff --git a/backend/app/admin/service/plugin_service.py b/backend/app/admin/service/plugin_service.py index 2c89160d..1ebd7728 100644 --- a/backend/app/admin/service/plugin_service.py +++ b/backend/app/admin/service/plugin_service.py @@ -15,8 +15,8 @@ from backend.common.exception import errors from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import redis_client -from backend.plugin.tools import uninstall_requirements_async -from backend.utils.file_ops import install_git_plugin, install_zip_plugin +from backend.plugin.installer import install_git_plugin, install_zip_plugin +from backend.plugin.requirements import uninstall_requirements_async from backend.utils.timezone import timezone diff --git a/backend/app/admin/utils/password_security.py b/backend/app/admin/utils/password_security.py index 5a361fd4..f7fbc287 100644 --- a/backend/app/admin/utils/password_security.py +++ b/backend/app/admin/utils/password_security.py @@ -6,7 +6,7 @@ from backend.app.admin.crud.crud_user_password_history import user_password_hist from backend.common.exception import errors from backend.core.conf import settings from backend.utils.dynamic_config import load_user_security_config -from backend.utils.re_verify import is_has_letter, is_has_number, is_has_special_char +from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char password_hash = PasswordHash((BcryptHasher(),)) diff --git a/backend/app/task/utils/schedulers.py b/backend/app/task/utils/schedulers.py index 57751837..a4152f9d 100644 --- a/backend/app/task/utils/schedulers.py +++ b/backend/app/task/utils/schedulers.py @@ -23,7 +23,7 @@ from backend.common.exception import errors from backend.core.conf import settings from backend.database.db import async_db_session from backend.database.redis import redis_client -from backend.utils._await import run_await +from backend.utils.async_helper import run_await from backend.utils.serializers import select_as_dict from backend.utils.timezone import timezone diff --git a/backend/cli.py b/backend/cli.py index 52b86972..a0f037cb 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -33,10 +33,11 @@ from backend.core.path_conf import ( ) from backend.database.db import async_db_session, create_tables, drop_tables from backend.database.redis import redis_client -from backend.plugin.tools import get_plugin_sql, get_plugins +from backend.plugin.core import get_plugin_sql, get_plugins +from backend.plugin.installer import install_git_plugin, install_zip_plugin from backend.utils.console import console -from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script -from backend.utils.import_parse import import_module_cached +from backend.utils.dynamic_import import import_module_cached +from backend.utils.sql_parser import parse_sql_script output_help = '\n更多信息,尝试 "[cyan]--help[/]"' diff --git a/backend/common/security/permission.py b/backend/common/security/permission.py index 7af13027..ae6e45bf 100644 --- a/backend/common/security/permission.py +++ b/backend/common/security/permission.py @@ -9,7 +9,7 @@ from backend.common.context import ctx from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType from backend.common.exception import errors from backend.core.conf import settings -from backend.utils.import_parse import get_all_models +from backend.utils.dynamic_import import get_all_models class RequestPermission: diff --git a/backend/common/security/rbac.py b/backend/common/security/rbac.py index ddd7f1fc..76d25c5b 100644 --- a/backend/common/security/rbac.py +++ b/backend/common/security/rbac.py @@ -6,7 +6,7 @@ from backend.common.exception import errors from backend.common.log import log from backend.common.security.jwt import DependsJwtAuth from backend.core.conf import settings -from backend.utils.import_parse import import_module_cached +from backend.utils.dynamic_import import import_module_cached async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901 diff --git a/backend/core/registrar.py b/backend/core/registrar.py index d6cdd1f1..cdff9089 100644 --- a/backend/core/registrar.py +++ b/backend/core/registrar.py @@ -29,10 +29,10 @@ from backend.middleware.i18n_middleware import I18nMiddleware from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware from backend.middleware.opera_log_middleware import OperaLogMiddleware from backend.middleware.state_middleware import StateMiddleware -from backend.plugin.tools import build_final_router -from backend.utils.demo_site import demo_site -from backend.utils.health_check import ensure_unique_route_names, http_limit_callback -from backend.utils.openapi import simplify_operation_ids +from backend.plugin.core import build_final_router +from backend.utils.demo_mode import demo_site +from backend.utils.limiter import http_limit_callback +from backend.utils.openapi import ensure_unique_route_names, simplify_operation_ids from backend.utils.otel import init_otel from backend.utils.serializers import MsgSpecJSONResponse from backend.utils.snowflake import snowflake diff --git a/backend/main.py b/backend/main.py index 2f0bce01..323ed073 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,8 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn from rich.text import Text from backend.core.registrar import register_app -from backend.plugin.tools import get_plugins, install_requirements +from backend.plugin.core import get_plugins +from backend.plugin.requirements import install_requirements from backend.utils.console import console from backend.utils.timezone import timezone diff --git a/backend/plugin/tools.py b/backend/plugin/core.py similarity index 62% rename from backend/plugin/tools.py rename to backend/plugin/core.py index cc6d34ed..156627b3 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/core.py @@ -1,19 +1,14 @@ import json import os -import subprocess -import sys import warnings from functools import lru_cache -from importlib.metadata import PackageNotFoundError, distribution from typing import Any import anyio import rtoml from fastapi import APIRouter, Depends, Request -from packaging.requirements import Requirement -from starlette.concurrency import run_in_threadpool from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType from backend.common.exception import errors @@ -21,8 +16,8 @@ from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import RedisCli, redis_client -from backend.utils._await import run_await -from backend.utils.import_parse import get_model_objects, import_module_cached +from backend.utils.async_helper import run_await +from backend.utils.dynamic_import import get_model_objects, import_module_cached class PluginConfigError(Exception): @@ -33,10 +28,6 @@ class PluginInjectError(Exception): """插件注入错误""" -class PluginInstallError(Exception): - """插件安装错误""" - - @lru_cache def get_plugins() -> list[str]: """获取插件列表""" @@ -278,155 +269,6 @@ def build_final_router() -> APIRouter: return main_router -def _ensure_pip_available() -> bool: - """确保 pip 在虚拟环境中可用""" - try: - result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True) - if result.returncode == 0: - return True - except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): - pass - - # 尝试使用 ensurepip - try: - subprocess.check_call( - [sys.executable, '-m', 'ensurepip', '--default-pip'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True) - if result.returncode == 0: - return True - except (subprocess.CalledProcessError, subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): - pass - - # 尝试下载并安装 - try: - import os - import tempfile - - import httpx - - try: - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: - with httpx.Client(timeout=3) as client: - get_pip_url = 'https://bootstrap.pypa.io/get-pip.py' - response = client.get(get_pip_url) - response.raise_for_status() - f.write(response.text) - temp_file = f.name - except Exception: # noqa: ignore - return False - - try: - subprocess.check_call([sys.executable, temp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True) - return result.returncode == 0 - finally: - try: - os.unlink(temp_file) - except OSError: - pass - except Exception: # noqa: ignore - pass - - return False - - -def install_requirements(plugin: str | None) -> None: # noqa: C901 - """ - 安装插件依赖 - - :param plugin: 指定插件名,否则检查所有插件 - :return: - """ - plugins = [plugin] if plugin else get_plugins() - - for plugin in plugins: - requirements_file = PLUGIN_DIR / plugin / 'requirements.txt' - missing_dependencies = False - if os.path.exists(requirements_file): - with open(requirements_file, encoding='utf-8') as f: - for line in f: - line = line.strip() - if not line or line.startswith('#'): - continue - try: - req = Requirement(line) - dependency = req.name.lower() - except Exception as e: - raise PluginInstallError(f'插件 {plugin} 依赖 {line} 格式错误: {e!s}') from e - try: - distribution(dependency) - except PackageNotFoundError: - missing_dependencies = True - - if missing_dependencies: - try: - if not _ensure_pip_available(): - raise PluginInstallError(f'pip 安装失败,无法继续安装插件 {plugin} 依赖') - - pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file] - if settings.PLUGIN_PIP_CHINA: - pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL]) - - max_retries = settings.PLUGIN_PIP_MAX_RETRY - for attempt in range(max_retries): - try: - subprocess.check_call( - pip_install, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - break - except subprocess.TimeoutExpired: - if attempt == max_retries - 1: - raise PluginInstallError(f'插件 {plugin} 依赖安装超时') - continue - except subprocess.CalledProcessError as e: - if attempt == max_retries - 1: - raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e - continue - except subprocess.CalledProcessError as e: - raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e - - -def uninstall_requirements(plugin: str) -> None: - """ - 卸载插件依赖 - - :param plugin: 插件名称 - :return: - """ - requirements_file = PLUGIN_DIR / plugin / 'requirements.txt' - if os.path.exists(requirements_file): - try: - pip_uninstall = [sys.executable, '-m', 'pip', 'uninstall', '-r', requirements_file, '-y'] - subprocess.check_call(pip_uninstall, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError as e: - raise PluginInstallError(f'插件 {plugin} 依赖卸载失败:{e}') from e - - -async def install_requirements_async(plugin: str | None = None) -> None: - """ - 异步安装插件依赖 - - 由于 Windows 平台限制,无法实现完美的全异步方案,详情: - https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows - """ - await run_in_threadpool(install_requirements, plugin) - - -async def uninstall_requirements_async(plugin: str) -> None: - """ - 异步卸载插件依赖 - - :param plugin: 插件名称 - :return: - """ - await run_in_threadpool(uninstall_requirements, plugin) - - class PluginStatusChecker: """插件状态检查器""" diff --git a/backend/plugin/installer.py b/backend/plugin/installer.py new file mode 100644 index 00000000..bd95bd23 --- /dev/null +++ b/backend/plugin/installer.py @@ -0,0 +1,100 @@ +import io +import os +import re +import zipfile + +import anyio + +from anyio import open_file +from dulwich import porcelain +from fastapi import UploadFile + +from backend.common.exception import errors +from backend.common.log import log +from backend.core.conf import settings +from backend.core.path_conf import PLUGIN_DIR +from backend.database.redis import redis_client +from backend.plugin.requirements import install_requirements_async +from backend.utils.pattern_validate import is_git_url + + +async def install_zip_plugin(file: UploadFile | str) -> str: + """ + 安装 ZIP 插件 + + :param file: FastAPI 上传文件对象或文件完整路径 + :return: + """ + if isinstance(file, str): + async with await open_file(file, mode='rb') as fb: + contents = await fb.read() + else: + contents = await file.read() + file_bytes = io.BytesIO(contents) + if not zipfile.is_zipfile(file_bytes): + raise errors.RequestError(msg='插件压缩包格式非法') + with zipfile.ZipFile(file_bytes) as zf: + # 校验压缩包 + plugin_namelist = zf.namelist() + plugin_dir_name = plugin_namelist[0].split('/')[0] + if not plugin_namelist: + raise errors.RequestError(msg='插件压缩包内容非法') + if ( + len(plugin_namelist) <= 3 + or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist + or f'{plugin_dir_name}/README.md' not in plugin_namelist + ): + raise errors.RequestError(msg='插件压缩包内缺少必要文件') + + # 插件是否可安装 + plugin_name = re.match( + r'^([a-zA-Z0-9_]+)', + file.split(os.sep)[-1].split('.')[0].strip() + if isinstance(file, str) + else file.filename.split('.')[0].strip(), + ).group() + full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name) + if await full_plugin_path.exists(): + raise errors.ConflictError(msg='此插件已安装') + await full_plugin_path.mkdir(parents=True, exist_ok=True) + + # 解压(安装) + members = [] + for member in zf.infolist(): + if member.filename.startswith(plugin_dir_name): + new_filename = member.filename.replace(plugin_dir_name, '') + if new_filename: + member.filename = new_filename + members.append(member) + zf.extractall(full_plugin_path, members) + + await install_requirements_async(plugin_dir_name) + await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') + + return plugin_name + + +async def install_git_plugin(repo_url: str) -> str: + """ + 安装 Git 插件 + + :param repo_url: + :return: + """ + match = is_git_url(repo_url) + if not match: + raise errors.RequestError(msg='Git 仓库地址格式非法') + repo_name = match.group('repo') + path = anyio.Path(PLUGIN_DIR / repo_name) + if await path.exists(): + raise errors.ConflictError(msg=f'{repo_name} 插件已安装') + try: + porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True) + except Exception as e: + log.error(f'插件安装失败: {e}') + raise errors.ServerError(msg='插件安装失败,请稍后重试') from e + + await install_requirements_async(repo_name) + await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') + + return repo_name diff --git a/backend/plugin/requirements.py b/backend/plugin/requirements.py new file mode 100644 index 00000000..c67d760c --- /dev/null +++ b/backend/plugin/requirements.py @@ -0,0 +1,175 @@ +import os +import subprocess +import sys + +from importlib.metadata import PackageNotFoundError, distribution + +from packaging.requirements import Requirement +from starlette.concurrency import run_in_threadpool + +from backend.core.conf import settings +from backend.core.path_conf import PLUGIN_DIR + + +class PluginInstallError(Exception): + """插件安装错误""" + + +def _ensure_pip_available() -> bool: + """确保 pip 在虚拟环境中可用""" + try: + result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True) + if result.returncode == 0: + return True + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): + pass + + # 尝试使用 ensurepip + try: + subprocess.check_call( + [sys.executable, '-m', 'ensurepip', '--default-pip'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True) + if result.returncode == 0: + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): + pass + + # 尝试下载并安装 + try: + import os + import tempfile + + import httpx + + try: + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + with httpx.Client(timeout=3) as client: + get_pip_url = 'https://bootstrap.pypa.io/get-pip.py' + response = client.get(get_pip_url) + response.raise_for_status() + f.write(response.text) + temp_file = f.name + except Exception: # noqa: ignore + return False + + try: + subprocess.check_call([sys.executable, temp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True) + return result.returncode == 0 + finally: + try: + os.unlink(temp_file) + except OSError: + pass + except Exception: # noqa: ignore + pass + + return False + + +def get_plugins() -> list[str]: + """ + 获取插件列表 + + 注意:此函数从 backend.plugin.core 导入以避免循环依赖 + """ + from backend.plugin.core import get_plugins as _get_plugins + + return _get_plugins() + + +def install_requirements(plugin: str | None) -> None: # noqa: C901 + """ + 安装插件依赖 + + :param plugin: 指定插件名,否则检查所有插件 + :return: + """ + plugins = [plugin] if plugin else get_plugins() + + for plugin in plugins: + requirements_file = PLUGIN_DIR / plugin / 'requirements.txt' + missing_dependencies = False + if os.path.exists(requirements_file): + with open(requirements_file, encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + try: + req = Requirement(line) + dependency = req.name.lower() + except Exception as e: + raise PluginInstallError(f'插件 {plugin} 依赖 {line} 格式错误: {e!s}') from e + try: + distribution(dependency) + except PackageNotFoundError: + missing_dependencies = True + + if missing_dependencies: + try: + if not _ensure_pip_available(): + raise PluginInstallError(f'pip 安装失败,无法继续安装插件 {plugin} 依赖') + + pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file] + if settings.PLUGIN_PIP_CHINA: + pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL]) + + max_retries = settings.PLUGIN_PIP_MAX_RETRY + for attempt in range(max_retries): + try: + subprocess.check_call( + pip_install, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + break + except subprocess.TimeoutExpired: + if attempt == max_retries - 1: + raise PluginInstallError(f'插件 {plugin} 依赖安装超时') + continue + except subprocess.CalledProcessError as e: + if attempt == max_retries - 1: + raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e + continue + except subprocess.CalledProcessError as e: + raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e + + +def uninstall_requirements(plugin: str) -> None: + """ + 卸载插件依赖 + + :param plugin: 插件名称 + :return: + """ + requirements_file = PLUGIN_DIR / plugin / 'requirements.txt' + if os.path.exists(requirements_file): + try: + pip_uninstall = [sys.executable, '-m', 'pip', 'uninstall', '-r', requirements_file, '-y'] + subprocess.check_call(pip_uninstall, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError as e: + raise PluginInstallError(f'插件 {plugin} 依赖卸载失败:{e}') from e + + +async def install_requirements_async(plugin: str | None = None) -> None: + """ + 异步安装插件依赖 + + 由于 Windows 平台限制,无法实现完美的全异步方案,详情: + https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows + """ + await run_in_threadpool(install_requirements, plugin) + + +async def uninstall_requirements_async(plugin: str) -> None: + """ + 异步卸载插件依赖 + + :param plugin: 插件名称 + :return: + """ + await run_in_threadpool(uninstall_requirements, plugin) diff --git a/backend/utils/_await.py b/backend/utils/async_helper.py similarity index 100% rename from backend/utils/_await.py rename to backend/utils/async_helper.py diff --git a/backend/utils/demo_site.py b/backend/utils/demo_mode.py similarity index 100% rename from backend/utils/demo_site.py rename to backend/utils/demo_mode.py diff --git a/backend/utils/import_parse.py b/backend/utils/dynamic_import.py similarity index 97% rename from backend/utils/import_parse.py rename to backend/utils/dynamic_import.py index 56555589..08aee91e 100644 --- a/backend/utils/import_parse.py +++ b/backend/utils/dynamic_import.py @@ -87,6 +87,6 @@ def get_app_models() -> list[object]: @lru_cache def get_all_models() -> list[object]: """获取所有模型类""" - from backend.plugin.tools import get_plugin_models + from backend.plugin.core import get_plugin_models return get_app_models() + get_plugin_models() diff --git a/backend/utils/file_ops.py b/backend/utils/file_ops.py index fa7aa948..a48b6295 100644 --- a/backend/utils/file_ops.py +++ b/backend/utils/file_ops.py @@ -1,23 +1,11 @@ -import io -import os -import re -import zipfile - -import anyio - from anyio import open_file -from dulwich import porcelain from fastapi import UploadFile -from sqlparse import split from backend.common.enums import FileType from backend.common.exception import errors from backend.common.log import log from backend.core.conf import settings -from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR -from backend.database.redis import redis_client -from backend.plugin.tools import install_requirements_async -from backend.utils.re_verify import is_git_url +from backend.core.path_conf import UPLOAD_DIR from backend.utils.timezone import timezone @@ -79,109 +67,3 @@ async def upload_file(file: UploadFile) -> str: raise errors.RequestError(msg='上传文件失败') await file.close() return filename - - -async def install_zip_plugin(file: UploadFile | str) -> str: - """ - 安装 ZIP 插件 - - :param file: FastAPI 上传文件对象或文件完整路径 - :return: - """ - if isinstance(file, str): - async with await open_file(file, mode='rb') as fb: - contents = await fb.read() - else: - contents = await file.read() - file_bytes = io.BytesIO(contents) - if not zipfile.is_zipfile(file_bytes): - raise errors.RequestError(msg='插件压缩包格式非法') - with zipfile.ZipFile(file_bytes) as zf: - # 校验压缩包 - plugin_namelist = zf.namelist() - plugin_dir_name = plugin_namelist[0].split('/')[0] - if not plugin_namelist: - raise errors.RequestError(msg='插件压缩包内容非法') - if ( - len(plugin_namelist) <= 3 - or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist - or f'{plugin_dir_name}/README.md' not in plugin_namelist - ): - raise errors.RequestError(msg='插件压缩包内缺少必要文件') - - # 插件是否可安装 - plugin_name = re.match( - r'^([a-zA-Z0-9_]+)', - file.split(os.sep)[-1].split('.')[0].strip() - if isinstance(file, str) - else file.filename.split('.')[0].strip(), - ).group() - full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name) - if await full_plugin_path.exists(): - raise errors.ConflictError(msg='此插件已安装') - await full_plugin_path.mkdir(parents=True, exist_ok=True) - - # 解压(安装) - members = [] - for member in zf.infolist(): - if member.filename.startswith(plugin_dir_name): - new_filename = member.filename.replace(plugin_dir_name, '') - if new_filename: - member.filename = new_filename - members.append(member) - zf.extractall(full_plugin_path, members) - - await install_requirements_async(plugin_dir_name) - await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') - - return plugin_name - - -async def install_git_plugin(repo_url: str) -> str: - """ - 安装 Git 插件 - - :param repo_url: - :return: - """ - match = is_git_url(repo_url) - if not match: - raise errors.RequestError(msg='Git 仓库地址格式非法') - repo_name = match.group('repo') - path = anyio.Path(PLUGIN_DIR / repo_name) - if await path.exists(): - raise errors.ConflictError(msg=f'{repo_name} 插件已安装') - try: - porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True) - except Exception as e: - log.error(f'插件安装失败: {e}') - raise errors.ServerError(msg='插件安装失败,请稍后重试') from e - - await install_requirements_async(repo_name) - await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture') - - return repo_name - - -async def parse_sql_script(filepath: str) -> list[str]: - """ - 解析 SQL 脚本 - - :param filepath: 脚本文件路径 - :return: - """ - path = anyio.Path(filepath) - if not await path.exists(): - raise errors.NotFoundError(msg='SQL 脚本文件不存在') - - async with await open_file(filepath, encoding='utf-8') as f: - contents = await f.read(1024) - while additional_contents := await f.read(1024): - contents += additional_contents - - statements = split(contents) - for statement in statements: - if not any(statement.lower().startswith(_) for _ in ['select', 'insert']): - raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT') - - return statements diff --git a/backend/utils/limiter.py b/backend/utils/limiter.py new file mode 100644 index 00000000..743c900a --- /dev/null +++ b/backend/utils/limiter.py @@ -0,0 +1,23 @@ +from math import ceil + +from fastapi import Request, Response + +from backend.common.exception import errors +from backend.common.response.response_code import StandardResponseCode + + +async def http_limit_callback(request: Request, response: Response, expire: int) -> None: # noqa: RUF029 + """ + 请求限制时的默认回调函数 + + :param request: FastAPI 请求对象 + :param response: FastAPI 响应对象 + :param expire: 剩余毫秒数 + :return: + """ + expires = ceil(expire / 1000) + raise errors.HTTPError( + code=StandardResponseCode.HTTP_429, + msg='请求过于频繁,请稍后重试', + headers={'Retry-After': str(expires)}, + ) diff --git a/backend/utils/openapi.py b/backend/utils/openapi.py index 3919d9ed..739e0572 100644 --- a/backend/utils/openapi.py +++ b/backend/utils/openapi.py @@ -12,3 +12,18 @@ def simplify_operation_ids(app: FastAPI) -> None: for route in app.routes: if isinstance(route, APIRoute): route.operation_id = route.name + + +def ensure_unique_route_names(app: FastAPI) -> None: + """ + 检查路由名称是否唯一 + + :param app: FastAPI 应用实例 + :return: + """ + temp_routes = set() + for route in app.routes: + if isinstance(route, APIRoute): + if route.name in temp_routes: + raise ValueError(f'Non-unique route name: {route.name}') + temp_routes.add(route.name) diff --git a/backend/utils/re_verify.py b/backend/utils/pattern_validate.py similarity index 100% rename from backend/utils/re_verify.py rename to backend/utils/pattern_validate.py diff --git a/backend/utils/health_check.py b/backend/utils/performance.py similarity index 51% rename from backend/utils/health_check.py rename to backend/utils/performance.py index de8dd63f..0879a320 100644 --- a/backend/utils/health_check.py +++ b/backend/utils/performance.py @@ -3,47 +3,9 @@ import functools import time from collections.abc import Callable -from math import ceil from typing import Any -from fastapi import FastAPI, Request, Response -from fastapi.routing import APIRoute - -from backend.common.exception import errors from backend.common.log import log -from backend.common.response.response_code import StandardResponseCode - - -def ensure_unique_route_names(app: FastAPI) -> None: - """ - 检查路由名称是否唯一 - - :param app: FastAPI 应用实例 - :return: - """ - temp_routes = set() - for route in app.routes: - if isinstance(route, APIRoute): - if route.name in temp_routes: - raise ValueError(f'Non-unique route name: {route.name}') - temp_routes.add(route.name) - - -async def http_limit_callback(request: Request, response: Response, expire: int) -> None: # noqa: RUF029 - """ - 请求限制时的默认回调函数 - - :param request: FastAPI 请求对象 - :param response: FastAPI 响应对象 - :param expire: 剩余毫秒数 - :return: - """ - expires = ceil(expire / 1000) - raise errors.HTTPError( - code=StandardResponseCode.HTTP_429, - msg='请求过于频繁,请稍后重试', - headers={'Retry-After': str(expires)}, - ) def timer(func) -> Callable: # noqa: ANN001 diff --git a/backend/utils/sql_parser.py b/backend/utils/sql_parser.py new file mode 100644 index 00000000..82aad2bc --- /dev/null +++ b/backend/utils/sql_parser.py @@ -0,0 +1,30 @@ +import anyio + +from anyio import open_file +from sqlparse import split + +from backend.common.exception import errors + + +async def parse_sql_script(filepath: str) -> list[str]: + """ + 解析 SQL 脚本 + + :param filepath: 脚本文件路径 + :return: + """ + path = anyio.Path(filepath) + if not await path.exists(): + raise errors.NotFoundError(msg='SQL 脚本文件不存在') + + async with await open_file(filepath, encoding='utf-8') as f: + contents = await f.read(1024) + while additional_contents := await f.read(1024): + contents += additional_contents + + statements = split(contents) + for statement in statements: + if not any(statement.lower().startswith(_) for _ in ['select', 'insert']): + raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT') + + return statements