mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 21:15:13 +00:00
Merge branch 'master' into pre-tenant
This commit is contained in:
@@ -2,13 +2,22 @@
|
||||
|
||||
代码生成器插件,生成通用业务代码
|
||||
|
||||
> [!TIP]
|
||||
> 当前版本仅包含后端代码生成
|
||||
- 支持维护代码生成业务配置与模型列信息
|
||||
- 支持手动模式和自动导表模式生成通用业务代码
|
||||
- 支持预览、写入磁盘和下载生成结果
|
||||
|
||||
> [!WARNING]
|
||||
> 由于 jinja2 在渲染模版时,文本方式输出可能存在格式问题,所以 `preview` 接口可能无法直观预览代码,这是为前端进行的预设
|
||||
## 插件类型
|
||||
|
||||
## 全局配置
|
||||
- 应用级插件
|
||||
|
||||
## 配置说明
|
||||
|
||||
插件目录下 `plugin.toml` 的 `[settings]` 中包含以下内容:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME = 'fba_generator'
|
||||
```
|
||||
|
||||
在 `backend/core/conf.py` 中添加以下内容:
|
||||
|
||||
@@ -16,38 +25,22 @@
|
||||
##################################################
|
||||
# [ Plugin ] code_generator
|
||||
##################################################
|
||||
# 基础配置(in plugin.toml)
|
||||
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str
|
||||
```
|
||||
|
||||
## 简介
|
||||
|
||||
代码生成器使用 API 调用实现,包含两个模组,设计可能存在缺陷,相关问题请直接提交 issues
|
||||
|
||||
### 代码生成业务
|
||||
|
||||
包含代码生成的相关配置,详情查看:`code_generator/model/gen_business.py`
|
||||
|
||||
### 代码生成模型列
|
||||
|
||||
包含代码生成所需要的模型列信息,就像正常定义模型列一样,目前支持的功能有限
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 启动后端服务,打开 swagger 文档直接操作
|
||||
2. 通过第三方 api 调试工具发送接口请求
|
||||
3. 同时启动前后端,从页面进行操作
|
||||
1. 安装并启用插件后,重启后端服务
|
||||
2. 维护业务配置和模型列信息
|
||||
3. 执行预览、生成和下载流程
|
||||
4. 生成代码会直接写入磁盘,仅必须在开发环境使用
|
||||
|
||||
接口参数基本都有说明,请注意查看
|
||||
## 卸载说明
|
||||
|
||||
### 手动模式
|
||||
- 卸载插件后,建议同步移除相关插件基础配置和 `backend/core/conf.py` 中的插件配置
|
||||
- 如项目中已接入代码生成相关页面或自动化流程,请同步清理对应集成
|
||||
|
||||
1. 通过创建业务接口手动添加一项业务数据
|
||||
2. 通过模型创建接口手动添加模型列
|
||||
3. 访问 `preview`(预览),`generate`(磁盘写入),`download`(下载)接口,执行后端代码生成相应工作
|
||||
## 联系方式
|
||||
|
||||
### 自动模式
|
||||
|
||||
1. 访问 `tables` 接口,获取数据库表名列表
|
||||
2. 通过 `import` 接口,导入数据库已有的数据库表数据,将自动创建业务表数据和模型表数据
|
||||
3. 访问 `preview`(预览),`generate`(磁盘写入),`download`(下载)接口,执行后端代码生成相应工作
|
||||
- 作者:`wu-clan`
|
||||
- 反馈方式:提交 Issue 或 PR
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
# Config
|
||||
|
||||
参数配置插件,通常用于动态配置系统参数和前端工程数据展示
|
||||
参数配置插件,通常用于动态维护系统参数和前端工程展示所需的配置数据
|
||||
|
||||
- 支持维护系统参数配置
|
||||
- 适合前端工程展示数据和系统级动态参数场景
|
||||
|
||||
## 插件类型
|
||||
|
||||
- 扩展级插件
|
||||
- 扩展目标:`admin`
|
||||
|
||||
## 配置说明
|
||||
|
||||
当前插件无需额外配置
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 安装并启用插件后,重启后端服务
|
||||
2. 通过系统后台页面或 Swagger 管理参数配置数据
|
||||
|
||||
## 卸载说明
|
||||
|
||||
- 卸载插件后无需额外清理环境变量或插件配置
|
||||
- 如业务代码或前端页面已依赖参数配置能力,请同步清理对应集成
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 作者:`wu-clan`
|
||||
- 反馈方式:提交 Issue 或 PR
|
||||
|
||||
+353
-286
@@ -11,7 +11,8 @@ import rtoml
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, Request
|
||||
|
||||
from backend.common.enums import DataBaseType, PluginLevelType, PrimaryKeyType, StatusType
|
||||
from backend.common.dataclasses import PluginEntry
|
||||
from backend.common.enums import DataBaseType, LifespanStage, PluginLevelType, PrimaryKeyType, StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.lifespan import lifespan_manager
|
||||
from backend.common.log import log
|
||||
@@ -34,14 +35,19 @@ def check_plugin_installed(plugin_name: str) -> bool:
|
||||
return (PLUGIN_DIR / plugin_name / '__init__.py').exists()
|
||||
|
||||
|
||||
def check_required_plugins() -> None:
|
||||
"""检查必需插件"""
|
||||
def get_required_plugins() -> tuple[str, ...]:
|
||||
"""获取必需插件列表"""
|
||||
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')
|
||||
return tuple(required_plugins)
|
||||
|
||||
|
||||
def check_required_plugins() -> None:
|
||||
"""检查必需插件"""
|
||||
required_plugins = get_required_plugins()
|
||||
missing_plugins = [name for name in required_plugins if not check_plugin_installed(name)]
|
||||
if missing_plugins:
|
||||
raise PluginInjectError(f'当前系统缺少以下插件: {", ".join(missing_plugins)},请先安装对应插件')
|
||||
@@ -65,6 +71,342 @@ def get_plugins() -> tuple[str, ...]:
|
||||
return tuple(plugin_packages)
|
||||
|
||||
|
||||
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 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 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 parse_plugin_config() -> tuple[list[PluginEntry], list[PluginEntry]]:
|
||||
"""解析插件配置"""
|
||||
plugins = get_plugins()
|
||||
extend_plugins: list[PluginEntry] = []
|
||||
app_plugins: list[PluginEntry] = []
|
||||
|
||||
# 使用独立连接
|
||||
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)
|
||||
|
||||
# 补充插件信息
|
||||
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)
|
||||
|
||||
plugin_entry = PluginEntry(
|
||||
name=plugin,
|
||||
depends_on=plugin_config['plugin'].get('depends_on'),
|
||||
extend=plugin_config['app']['extend'] if plugin_type == PluginLevelType.extend else None,
|
||||
routers=plugin_config['app']['router'] if plugin_type == PluginLevelType.app else None,
|
||||
api=plugin_config['api'] if plugin_type == PluginLevelType.extend else None,
|
||||
)
|
||||
|
||||
if plugin_type == PluginLevelType.extend:
|
||||
extend_plugins.append(plugin_entry)
|
||||
else:
|
||||
app_plugins.append(plugin_entry)
|
||||
|
||||
# 缓存最新插件信息
|
||||
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 resolve_plugin_order(plugins: list[PluginEntry]) -> list[PluginEntry]:
|
||||
"""
|
||||
根据 depends_on 对插件排序
|
||||
|
||||
:param plugins: 插件配置列表
|
||||
:return:
|
||||
"""
|
||||
plugin_map = {plugin.name: plugin for plugin in plugins}
|
||||
ordered_plugins: list[PluginEntry] = []
|
||||
visited: set[str] = set()
|
||||
visiting: list[str] = []
|
||||
|
||||
def visit(plugin: PluginEntry) -> None:
|
||||
if plugin.name in visited:
|
||||
return
|
||||
if plugin.name in visiting:
|
||||
cycle_start = visiting.index(plugin.name)
|
||||
cycle_path = [*visiting[cycle_start:], plugin.name]
|
||||
raise PluginConfigError(f'插件存在循环依赖: {" -> ".join(cycle_path)}')
|
||||
|
||||
if plugin.depends_on is not None:
|
||||
visiting.append(plugin.name)
|
||||
for dep_name in plugin.depends_on:
|
||||
dep_plugin = plugin_map.get(dep_name)
|
||||
if dep_plugin is None:
|
||||
raise PluginConfigError(f'插件 {plugin.name} 依赖插件 {dep_name},但插件 {dep_name} 不存在')
|
||||
visit(dep_plugin)
|
||||
visiting.pop()
|
||||
|
||||
visited.add(plugin.name)
|
||||
ordered_plugins.append(plugin)
|
||||
|
||||
for plugin in plugins:
|
||||
visit(plugin)
|
||||
|
||||
return ordered_plugins
|
||||
|
||||
|
||||
def build_final_router() -> APIRouter:
|
||||
"""构建最终路由"""
|
||||
extend_plugins, app_plugins = parse_plugin_config()
|
||||
plugins = extend_plugins + app_plugins
|
||||
ordered_plugins = resolve_plugin_order(plugins)
|
||||
|
||||
for plugin in ordered_plugins:
|
||||
if plugin.api is not None:
|
||||
inject_extend_router(plugin)
|
||||
|
||||
# 主路由,必须在扩展级插件路由注入后,应用级插件路由注入前导入
|
||||
from backend.app.router import router as main_router
|
||||
|
||||
for plugin in ordered_plugins:
|
||||
if plugin.routers is not None:
|
||||
inject_app_router(plugin, main_router)
|
||||
|
||||
return main_router
|
||||
|
||||
|
||||
def inject_extend_router(plugin: PluginEntry) -> None:
|
||||
"""
|
||||
扩展级插件路由注入
|
||||
|
||||
:param plugin: 插件名称
|
||||
:return:
|
||||
"""
|
||||
plugin_api_path = PLUGIN_DIR / plugin.name / 'api'
|
||||
if not os.path.exists(plugin_api_path):
|
||||
raise PluginConfigError(f'插件 {plugin.name} 缺少 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.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: PluginEntry, target_router: APIRouter) -> None:
|
||||
"""
|
||||
应用级插件路由注入
|
||||
|
||||
:param plugin: 插件名称
|
||||
:param target_router: FastAPI 路由器
|
||||
:return:
|
||||
"""
|
||||
module_path = f'backend.plugin.{plugin.name}.api.router'
|
||||
try:
|
||||
module = import_module_cached(module_path)
|
||||
routers = plugin.routers
|
||||
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 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, stage=LifespanStage.plugin) # type: ignore[call-overload]
|
||||
log.info(f'插件 {plugin} lifespan hook 注册成功')
|
||||
|
||||
|
||||
def run_plugin_setup_hook(plugin: str, module: Any, app: FastAPI) -> None:
|
||||
"""
|
||||
执行插件 setup 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} setup hook 执行成功')
|
||||
|
||||
|
||||
def setup_plugins(app: FastAPI) -> None:
|
||||
"""
|
||||
注册并执行插件 hooks
|
||||
|
||||
:param app: FastAPI 应用实例
|
||||
:return:
|
||||
"""
|
||||
enabled_plugins = get_enabled_plugins()
|
||||
extend_plugins, app_plugins = parse_plugin_config()
|
||||
plugins: list[PluginEntry] = [plugin for plugin in extend_plugins + app_plugins if plugin.name in enabled_plugins]
|
||||
|
||||
# 按插件依赖关系排序
|
||||
try:
|
||||
ordered_plugins = resolve_plugin_order(plugins)
|
||||
except PluginConfigError as e:
|
||||
log.error(f'插件依赖解析失败: {e}')
|
||||
raise
|
||||
|
||||
# 注册并执行 hooks
|
||||
for plugin in ordered_plugins:
|
||||
module_path = f'backend.plugin.{plugin.name}.hooks'
|
||||
try:
|
||||
module = import_module_cached(module_path)
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name == module_path:
|
||||
continue
|
||||
log.warning(f'插件 {plugin.name} hooks 加载失败: {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
log.warning(f'插件 {plugin.name} hooks 加载失败: {e}')
|
||||
continue
|
||||
|
||||
try:
|
||||
register_plugin_lifespan_hook(plugin.name, module)
|
||||
run_plugin_setup_hook(plugin.name, module, app)
|
||||
except Exception as e:
|
||||
log.exception(f'插件 {plugin.name} hooks 执行失败: {e}')
|
||||
raise PluginInjectError(f'插件 {plugin.name} hooks 执行失败:{e!s}') from e
|
||||
|
||||
|
||||
def get_plugin_models() -> list[object]:
|
||||
"""获取插件所有模型类"""
|
||||
objs = []
|
||||
@@ -85,6 +427,14 @@ def build_sql_filename(
|
||||
suffix: str | None = None,
|
||||
tenant: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
构建插件 SQL 脚本文件名
|
||||
|
||||
:param prefix: SQL 脚本文件名前缀,例如 init 或 destroy
|
||||
:param pk_type: 主键类型,雪花 ID 模式会追加 snowflake 标识
|
||||
:param suffix: 可选文件名后缀,追加在主键类型标识之后
|
||||
:return:
|
||||
"""
|
||||
parts = [prefix]
|
||||
if pk_type == PrimaryKeyType.snowflake:
|
||||
parts.append('snowflake')
|
||||
@@ -133,289 +483,6 @@ async def get_plugin_destroy_sql(plugin: str, db_type: DataBaseType, pk_type: Pr
|
||||
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:
|
||||
"""插件状态检查器"""
|
||||
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
# Dict
|
||||
|
||||
数据字典插件,通常用于约束前端工程数据展示
|
||||
数据字典插件,通常用于维护字典类型、字典数据,并约束前端工程中的枚举展示与选项映射
|
||||
|
||||
- 支持维护字典类型和字典数据
|
||||
- 适合前端状态值、分类值、下拉选项等统一字典场景
|
||||
|
||||
## 插件类型
|
||||
|
||||
- 扩展级插件
|
||||
- 扩展目标:`admin`
|
||||
|
||||
## 配置说明
|
||||
|
||||
当前插件无需额外配置
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 安装并启用插件后,重启后端服务
|
||||
2. 先维护字典类型,再维护对应的字典数据
|
||||
3. 使用稳定的字典编码维护前端枚举与选项映射
|
||||
|
||||
## 卸载说明
|
||||
|
||||
- 卸载插件后无需额外清理环境变量或插件配置
|
||||
- 如前端或业务代码已依赖字典能力与字典编码,请同步清理对应集成
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 作者:`wu-clan`
|
||||
- 反馈方式:提交 Issue 或 PR
|
||||
|
||||
@@ -35,7 +35,7 @@ class CRUDDictData(CRUDPlus[DictData]):
|
||||
sort_columns='sort',
|
||||
sort_orders='desc',
|
||||
type_code=type_code,
|
||||
status=StatusType.enable,
|
||||
status=StatusType.enable.value,
|
||||
)
|
||||
|
||||
async def get_all(self, db: AsyncSession) -> Sequence[DictData]:
|
||||
|
||||
@@ -2,7 +2,34 @@
|
||||
|
||||
电子邮件插件,提供邮件发送功能,支持验证码、通知等场景
|
||||
|
||||
## 全局配置
|
||||
- 支持 SMTP 邮件发送
|
||||
- 支持验证码、通知等邮件场景
|
||||
- 支持按基础配置控制邮箱服务、验证码有效期和 Redis 前缀
|
||||
|
||||
## 插件类型
|
||||
|
||||
- 应用级插件
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `backend/.env` 中添加以下内容:
|
||||
|
||||
```env
|
||||
# [ Plugin ] email
|
||||
EMAIL_USERNAME=''
|
||||
EMAIL_PASSWORD=''
|
||||
```
|
||||
|
||||
插件目录下 `plugin.toml` 的 `[settings]` 中包含以下内容:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
EMAIL_CAPTCHA_EXPIRE_SECONDS = 180
|
||||
EMAIL_CAPTCHA_REDIS_PREFIX = 'fba:email:captcha'
|
||||
EMAIL_HOST = 'smtp.qq.com'
|
||||
EMAIL_PORT = 465
|
||||
EMAIL_SSL = true
|
||||
```
|
||||
|
||||
在 `backend/core/conf.py` 中添加以下内容:
|
||||
|
||||
@@ -21,3 +48,19 @@ EMAIL_SSL: bool
|
||||
EMAIL_CAPTCHA_REDIS_PREFIX: str
|
||||
EMAIL_CAPTCHA_EXPIRE_SECONDS: int
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 安装并启用插件后,配置正确的 SMTP 账号与密码
|
||||
2. 根据实际邮箱服务商修改 `EMAIL_HOST`、`EMAIL_PORT`、`EMAIL_SSL`
|
||||
3. 重启后端服务后,通过系统页面、Swagger 或业务代码使用邮件能力
|
||||
|
||||
## 卸载说明
|
||||
|
||||
- 卸载插件后,建议同步移除相关环境变量、插件基础配置和 `backend/core/conf.py` 中的插件配置
|
||||
- 如业务代码仍在使用邮件发送能力,请同步清理对应集成
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 作者:`wu-clan`
|
||||
- 反馈方式:提交 Issue 或 PR
|
||||
|
||||
@@ -157,7 +157,7 @@ async def install_git_plugin(repo_url: str) -> str:
|
||||
return repo_name
|
||||
|
||||
|
||||
async def install_git_frontend_plugin(repo_url: str, frontend_project_root: str) -> str: # noqa: C901
|
||||
async def install_git_frontend_plugin(repo_url: str, frontend_project_root: str) -> str:
|
||||
"""
|
||||
安装前端 Git 插件
|
||||
|
||||
@@ -193,12 +193,7 @@ async def install_git_frontend_plugin(repo_url: str, frontend_project_root: str)
|
||||
|
||||
try:
|
||||
await run_in_threadpool(porcelain.clone, repo_url, plugins_dir / plugin_name, checkout=True)
|
||||
git_dir = anyio.Path(plugins_dir / plugin_name / '.git')
|
||||
if await git_dir.exists():
|
||||
await run_in_threadpool(remove_plugin, plugins_dir / plugin_name / '.git')
|
||||
except Exception as e:
|
||||
if await target_path.exists():
|
||||
await run_in_threadpool(remove_plugin, plugins_dir / plugin_name)
|
||||
log.error(f'前端插件安装失败: {e}')
|
||||
raise errors.ServerError(msg='前端插件安装失败,请稍后重试') from e
|
||||
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
# Notice
|
||||
|
||||
通知公告插件,用于发布系统内部通知、公告
|
||||
|
||||
- 支持维护系统通知和公告内容
|
||||
- 适合后台公告、站内通知等场景
|
||||
|
||||
## 插件类型
|
||||
|
||||
- 扩展级插件
|
||||
- 扩展目标:`admin`
|
||||
|
||||
## 配置说明
|
||||
|
||||
当前插件无需额外配置
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 安装并启用插件后,重启后端服务
|
||||
2. 通过后台页面或 Swagger 维护通知公告内容
|
||||
|
||||
## 卸载说明
|
||||
|
||||
- 卸载插件后无需额外清理环境变量或插件配置
|
||||
- 如前端页面或业务逻辑已依赖通知公告能力,请同步清理对应集成
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 作者:`wu-clan`
|
||||
- 反馈方式:提交 Issue 或 PR
|
||||
|
||||
@@ -2,7 +2,37 @@
|
||||
|
||||
OAuth 2.0 第三方登录插件,支持 GitHub、Google 等社交平台登录
|
||||
|
||||
## 全局配置
|
||||
- 支持 GitHub、Google 第三方登录
|
||||
- 支持第三方账号绑定与解绑
|
||||
- 支持登录回跳和绑定回跳配置
|
||||
|
||||
## 插件类型
|
||||
|
||||
- 应用级插件
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `backend/.env` 中添加以下内容:
|
||||
|
||||
```env
|
||||
# [ Plugin ] oauth2
|
||||
OAUTH2_GITHUB_CLIENT_ID='test'
|
||||
OAUTH2_GITHUB_CLIENT_SECRET='test'
|
||||
OAUTH2_GOOGLE_CLIENT_ID='test'
|
||||
OAUTH2_GOOGLE_CLIENT_SECRET='test'
|
||||
```
|
||||
|
||||
插件目录下 `plugin.toml` 的 `[settings]` 中包含以下内容:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
OAUTH2_FRONTEND_BINDING_REDIRECT_URI = 'http://localhost:5173/profile'
|
||||
OAUTH2_FRONTEND_LOGIN_REDIRECT_URI = 'http://localhost:5173/oauth2/callback'
|
||||
OAUTH2_GITHUB_REDIRECT_URI = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback'
|
||||
OAUTH2_GOOGLE_REDIRECT_URI = 'http://127.0.0.1:8000/api/v1/oauth2/google/callback'
|
||||
OAUTH2_STATE_EXPIRE_SECONDS = 180
|
||||
OAUTH2_STATE_REDIS_PREFIX = 'fba:oauth2:state'
|
||||
```
|
||||
|
||||
在 `backend/core/conf.py` 中添加以下内容:
|
||||
|
||||
@@ -24,3 +54,21 @@ OAUTH2_GOOGLE_REDIRECT_URI: str
|
||||
OAUTH2_FRONTEND_LOGIN_REDIRECT_URI: str
|
||||
OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 安装并启用插件后,在 GitHub、Google 开放平台分别创建 OAuth 应用
|
||||
2. 将平台分配的 Client ID、Client Secret 配置到项目环境变量中
|
||||
3. 确保平台回调地址与 `OAUTH2_GITHUB_REDIRECT_URI`、`OAUTH2_GOOGLE_REDIRECT_URI` 保持一致
|
||||
4. 配置前端登录回跳地址与绑定回跳地址
|
||||
5. 重启后端服务后,使用第三方登录、绑定和解绑能力
|
||||
|
||||
## 卸载说明
|
||||
|
||||
- 卸载插件后,建议同步移除相关环境变量、插件基础配置和 `backend/core/conf.py` 中的插件配置
|
||||
- 如前端登录页或个人中心已集成第三方登录、绑定等能力,请同步清理对应集成
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 作者:`wu-clan`
|
||||
- 反馈方式:提交 Issue 或 PR
|
||||
|
||||
@@ -9,7 +9,7 @@ def replace_middleware(
|
||||
**replacement_kwargs,
|
||||
) -> None:
|
||||
"""
|
||||
替换中间件(应在插件的 startup hook 中调用)
|
||||
替换中间件(应在插件的 setup hook 中调用)
|
||||
|
||||
:param app: FastAPI 应用实例
|
||||
:param original_middleware_cls: 原始中间件类
|
||||
|
||||
@@ -25,6 +25,7 @@ class PluginInfoSchema(BaseModel):
|
||||
author: str = Field(..., min_length=1, max_length=50, description='作者')
|
||||
tags: list[str] = Field(..., min_length=1, description='标签')
|
||||
database: list[str] = Field(..., min_length=1, description='数据库支持')
|
||||
depends_on: list[str] = Field(default_factory=list, description='依赖的插件列表')
|
||||
|
||||
@field_validator('version')
|
||||
@classmethod
|
||||
@@ -58,6 +59,15 @@ class PluginInfoSchema(BaseModel):
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('depends_on')
|
||||
@classmethod
|
||||
def validate_depends_on(cls, v: list[str]) -> list[str]:
|
||||
"""校验插件依赖配置"""
|
||||
for dep in v:
|
||||
if not dep or not isinstance(dep, str):
|
||||
raise PluginConfigError(f'依赖的插件列表必须为非空字符串,当前值: {dep}')
|
||||
return v
|
||||
|
||||
|
||||
class AppPluginAppSchema(BaseModel):
|
||||
"""应用级插件 app 配置模型"""
|
||||
@@ -183,6 +193,10 @@ def validate_plugin_config(plugin_name: str, config: dict[str, Any]) -> PluginLe
|
||||
error_msg = '; '.join(error_details)
|
||||
raise PluginConfigError(f'插件 {plugin_name} 配置校验失败: {error_msg}') from e
|
||||
|
||||
depends_on = config['plugin'].get('depends_on', [])
|
||||
if plugin_name in depends_on:
|
||||
raise PluginConfigError(f'插件 {plugin_name} 不能依赖自身')
|
||||
|
||||
plugin_dir = Path(PLUGIN_DIR) / plugin_name
|
||||
model_dir = plugin_dir / 'model'
|
||||
if model_dir.is_dir():
|
||||
|
||||
Reference in New Issue
Block a user