Files
fastapi-best-architecture/backend/plugin/core.py
T
Wu Clan 1f287bbb0c Merge branch 'master' into pre-tenant
# Conflicts:
#	.pre-commit-config.yaml
#	pyproject.toml
#	requirements.txt
#	uv.lock
2026-04-08 21:04:56 +08:00

445 lines
15 KiB
Python

import inspect
import json
import os
import warnings
from functools import lru_cache
from typing import Any
import anyio
import rtoml
from fastapi import APIRouter, Depends, FastAPI, Request
from backend.common.enums import DataBaseType, PluginLevelType, PrimaryKeyType, StatusType
from backend.common.exception import errors
from backend.common.lifespan import lifespan_manager
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.plugin.errors import PluginConfigError, PluginInjectError
from backend.plugin.validator import validate_plugin_config
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 settings.TENANT_ENABLED and 'tenant' not in required_plugins:
required_plugins.append('tenant')
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, ...]:
"""获取插件列表"""
plugin_packages = []
# 遍历插件目录
for item in os.listdir(PLUGIN_DIR):
item_path = PLUGIN_DIR / item
if not os.path.isdir(item_path) and item == '__pycache__':
continue
# 检查是否为目录且包含 __init__.py 文件
if os.path.isdir(item_path) and '__init__.py' in os.listdir(item_path):
plugin_packages.append(item)
return tuple(plugin_packages)
def get_plugin_models() -> list[object]:
"""获取插件所有模型类"""
objs = []
for plugin in get_plugins():
module_path = f'backend.plugin.{plugin}.model'
model_objs = get_model_objects(module_path)
if model_objs:
objs.extend(model_objs)
return objs
def build_sql_filename(
prefix: str,
pk_type: PrimaryKeyType,
*,
suffix: str | None = None,
tenant: bool = False,
) -> str:
parts = [prefix]
if pk_type == PrimaryKeyType.snowflake:
parts.append('snowflake')
if suffix:
parts.append(suffix)
if tenant:
parts.append('tenant')
return f'{"_".join(parts)}.sql'
async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
"""
获取插件 SQL 脚本
:param plugin: 插件名称
:param db_type: 数据库类型
:param pk_type: 主键类型
:return:
"""
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
default_filename = build_sql_filename('init', pk_type)
if not settings.TENANT_ENABLED:
sql_file = sql_dir / default_filename
return str(sql_file) if await anyio.Path(sql_file).exists() else None
tenant_filename = build_sql_filename('init', pk_type, tenant=True)
tenant_sql_file = sql_dir / tenant_filename
if await anyio.Path(tenant_sql_file).exists():
return str(tenant_sql_file)
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:
"""
获取插件销毁 SQL 脚本
:param plugin: 插件名称
:param db_type: 数据库类型
:param pk_type: 主键类型
:return:
"""
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]:
"""
加载插件配置
:param plugin: 插件名称
:return:
"""
toml_path = PLUGIN_DIR / plugin / 'plugin.toml'
if not os.path.exists(toml_path):
raise PluginInjectError(f'插件 {plugin} 缺少 plugin.toml 配置文件,请检查插件是否合法')
with open(toml_path, encoding='utf-8') as f:
return rtoml.load(f)
def get_plugin_enable(plugin_info: str | None, default_status: int) -> str:
"""
解析插件启用状态
:param plugin_info: 插件缓存信息
:param default_status: 默认状态值
:return:
"""
if not plugin_info:
return str(default_status)
try:
return json.loads(plugin_info)['plugin']['enable']
except Exception:
return str(default_status)
def get_enabled_plugins(plugins: tuple[str, ...] | None = None) -> set[str]:
"""
获取已启用的插件列表
:param plugins: 插件名称列表
:return:
"""
plugin_names = plugins or get_plugins()
enabled_plugins = set(plugin_names)
current_redis_client = RedisCli()
run_await(current_redis_client.init)()
try:
for plugin in plugin_names:
plugin_info = run_await(current_redis_client.get)(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
if get_plugin_enable(plugin_info, StatusType.enable.value) != str(StatusType.enable.value):
enabled_plugins.discard(plugin)
finally:
run_await(current_redis_client.aclose)()
return enabled_plugins
def register_plugin_lifespan_hook(plugin: str, module: Any) -> None:
"""
注册插件 lifespan hook
:param plugin: 插件名称
:param module: 插件 hooks 模块
:return:
"""
lifespan_hook = getattr(module, 'lifespan', None)
if lifespan_hook is None:
return
if not callable(lifespan_hook):
log.warning(f'插件 {plugin} 的 lifespan 不是可调用对象,已跳过')
return
lifespan_manager.register(lifespan_hook)
log.info(f'插件 {plugin} lifespan hook 注册成功')
def run_plugin_startup_hook(plugin: str, module: Any, app: FastAPI) -> None:
"""
执行插件 startup hook
:param plugin: 插件名称
:param module: 插件 hooks 模块
:param app: FastAPI 应用实例
:return:
"""
setup_hook = getattr(module, 'setup', None)
if setup_hook is None:
return
if not callable(setup_hook):
log.warning(f'插件 {plugin} 的 setup 不是可调用对象,已跳过')
return
setup_result = setup_hook(app)
if inspect.isawaitable(setup_result):
run_await(lambda: setup_result)() # type: ignore
log.info(f'插件 {plugin} startup hook 执行成功')
def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""解析插件配置"""
extend_plugins = []
app_plugins = []
plugins = get_plugins()
# 使用独立连接
current_redis_client = RedisCli()
run_await(current_redis_client.init)()
try:
# 清理未知插件信息
exclude_keys = [f'{settings.PLUGIN_REDIS_PREFIX}:{key}' for key in plugins]
run_await(current_redis_client.delete_prefix)(
settings.PLUGIN_REDIS_PREFIX,
exclude=exclude_keys,
)
for plugin in plugins:
plugin_config = load_plugin_config(plugin)
plugin_type = validate_plugin_config(plugin, plugin_config)
if plugin_type == PluginLevelType.extend:
extend_plugins.append(plugin_config)
else:
app_plugins.append(plugin_config)
# 补充插件信息
plugin_config['plugin']['name'] = plugin
plugin_cache_key = f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}'
plugin_cache_info = run_await(current_redis_client.get)(plugin_cache_key)
plugin_config['plugin']['enable'] = get_plugin_enable(plugin_cache_info, StatusType.enable.value)
# 缓存最新插件信息
run_await(current_redis_client.set)(plugin_cache_key, json.dumps(plugin_config, ensure_ascii=False))
# 重置插件变更状态
run_await(current_redis_client.delete)(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
finally:
run_await(current_redis_client.aclose)()
return extend_plugins, app_plugins
def inject_extend_router(plugin: dict[str, Any]) -> None:
"""
扩展级插件路由注入
:param plugin: 插件名称
:return:
"""
plugin_name: str = plugin['plugin']['name']
plugin_api_path = PLUGIN_DIR / plugin_name / 'api'
if not os.path.exists(plugin_api_path):
raise PluginConfigError(f'插件 {plugin} 缺少 api 目录,请检查插件文件是否完整')
for root, _, api_files in os.walk(plugin_api_path):
for file in api_files:
if not (file.endswith('.py') and file != '__init__.py'):
continue
# 解析插件路由配置
file_config = plugin['api'][file[:-3]]
prefix = file_config['prefix']
tags = file_config['tags']
# 获取插件路由模块
file_path = os.path.join(root, file)
path_to_module_str = os.path.relpath(file_path, PLUGIN_DIR).replace(os.sep, '.')[:-3]
module_path = f'backend.plugin.{path_to_module_str}'
try:
module = import_module_cached(module_path)
plugin_router = getattr(module, 'router', None)
if not plugin_router:
warnings.warn(
f'扩展级插件 {plugin_name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整',
FutureWarning,
)
continue
# 获取目标 app 路由
relative_path = os.path.relpath(root, plugin_api_path)
app_name = plugin.get('app', {}).get('extend')
target_module_path = f'backend.app.{app_name}.api.{relative_path.replace(os.sep, ".")}'
target_module = import_module_cached(target_module_path)
target_router = getattr(target_module, 'router', None)
if not target_router or not isinstance(target_router, APIRouter):
raise PluginInjectError(
f'扩展级插件 {plugin_name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整',
)
# 将插件路由注入到目标路由中
target_router.include_router(
router=plugin_router,
prefix=prefix,
tags=[tags] if tags else [],
dependencies=[Depends(PluginStatusChecker(plugin_name))],
)
except Exception as e:
raise PluginInjectError(f'扩展级插件 {plugin_name} 路由注入失败:{e!s}') from e
def inject_app_router(plugin: dict[str, Any], target_router: APIRouter) -> None:
"""
应用级插件路由注入
:param plugin: 插件名称
:param target_router: FastAPI 路由器
:return:
"""
plugin_name: str = plugin['plugin']['name']
module_path = f'backend.plugin.{plugin_name}.api.router'
try:
module = import_module_cached(module_path)
routers = plugin['app']['router']
if not routers or not isinstance(routers, list):
raise PluginConfigError(f'应用级插件 {plugin_name} 配置文件存在错误,请检查')
for router in routers:
plugin_router = getattr(module, router, None)
if not plugin_router or not isinstance(plugin_router, APIRouter):
raise PluginInjectError(
f'应用级插件 {plugin_name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整',
)
# 将插件路由注入到目标路由中
target_router.include_router(plugin_router, dependencies=[Depends(PluginStatusChecker(plugin_name))])
except Exception as e:
raise PluginInjectError(f'应用级插件 {plugin_name} 路由注入失败:{e!s}') from e
def build_final_router() -> APIRouter:
"""构建最终路由"""
extend_plugins, app_plugins = parse_plugin_config()
for plugin in extend_plugins:
inject_extend_router(plugin)
# 主路由,必须在扩展级插件路由注入后,应用级插件路由注入前导入
from backend.app.router import router as main_router
for plugin in app_plugins:
inject_app_router(plugin, main_router)
return main_router
def setup_plugins(app: FastAPI) -> None:
"""
注册并执行插件 hooks
:param app: FastAPI 应用实例
:return:
"""
plugins = get_plugins()
enabled_plugins = get_enabled_plugins(plugins)
for plugin in plugins:
if plugin not in enabled_plugins:
log.info(f'插件 {plugin} 未启用,已跳过 hooks 注册与执行')
continue
module_path = f'backend.plugin.{plugin}.hooks'
try:
module = import_module_cached(module_path)
except ModuleNotFoundError as e:
if e.name == module_path:
# 未定义 hooks.py
continue
log.warning(f'插件 {plugin} hooks 模块加载失败: {e}')
continue
except Exception as e:
log.warning(f'插件 {plugin} hooks 模块加载失败: {e}')
continue
try:
register_plugin_lifespan_hook(plugin, module)
run_plugin_startup_hook(plugin, module, app)
except Exception as e:
log.error(f'插件 {plugin} hooks 执行失败: {e}')
class PluginStatusChecker:
"""插件状态检查器"""
def __init__(self, plugin: str) -> None:
"""
初始化插件状态检查器
:param plugin: 插件名称
:return:
"""
self.plugin = plugin
async def __call__(self, request: Request) -> None:
"""
验证插件状态
:param request: FastAPI 请求对象
:return:
"""
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:{self.plugin}')
if not plugin_info:
log.error('插件状态未初始化或丢失,需重启服务自动修复')
raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员')
if get_plugin_enable(plugin_info, StatusType.disable.value) != str(StatusType.enable.value):
raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')