Merge branch 'master' into pre-tenant

This commit is contained in:
Wu Clan
2026-04-28 18:04:53 +08:00
52 changed files with 2670 additions and 2036 deletions
+24 -31
View File
@@ -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
+28 -1
View File
@@ -1,3 +1,30 @@
# Config
参数配置插件,通常用于动态配置系统参数和前端工程数据展示
参数配置插件,通常用于动态维护系统参数和前端工程展示所需的配置数据
- 支持维护系统参数配置
- 适合前端工程展示数据和系统级动态参数场景
## 插件类型
- 扩展级插件
- 扩展目标:`admin`
## 配置说明
当前插件无需额外配置
## 使用方式
1. 安装并启用插件后,重启后端服务
2. 通过系统后台页面或 Swagger 管理参数配置数据
## 卸载说明
- 卸载插件后无需额外清理环境变量或插件配置
- 如业务代码或前端页面已依赖参数配置能力,请同步清理对应集成
## 联系方式
- 作者:`wu-clan`
- 反馈方式:提交 Issue 或 PR
+353 -286
View File
@@ -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:
"""插件状态检查器"""
+29 -1
View File
@@ -1,3 +1,31 @@
# Dict
数据字典插件,通常用于约束前端工程数据展示
数据字典插件,通常用于维护字典类型、字典数据,并约束前端工程中的枚举展示与选项映射
- 支持维护字典类型和字典数据
- 适合前端状态值、分类值、下拉选项等统一字典场景
## 插件类型
- 扩展级插件
- 扩展目标:`admin`
## 配置说明
当前插件无需额外配置
## 使用方式
1. 安装并启用插件后,重启后端服务
2. 先维护字典类型,再维护对应的字典数据
3. 使用稳定的字典编码维护前端枚举与选项映射
## 卸载说明
- 卸载插件后无需额外清理环境变量或插件配置
- 如前端或业务代码已依赖字典能力与字典编码,请同步清理对应集成
## 联系方式
- 作者:`wu-clan`
- 反馈方式:提交 Issue 或 PR
+1 -1
View File
@@ -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]:
+44 -1
View File
@@ -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
+1 -6
View File
@@ -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
+27
View File
@@ -1,3 +1,30 @@
# Notice
通知公告插件,用于发布系统内部通知、公告
- 支持维护系统通知和公告内容
- 适合后台公告、站内通知等场景
## 插件类型
- 扩展级插件
- 扩展目标:`admin`
## 配置说明
当前插件无需额外配置
## 使用方式
1. 安装并启用插件后,重启后端服务
2. 通过后台页面或 Swagger 维护通知公告内容
## 卸载说明
- 卸载插件后无需额外清理环境变量或插件配置
- 如前端页面或业务逻辑已依赖通知公告能力,请同步清理对应集成
## 联系方式
- 作者:`wu-clan`
- 反馈方式:提交 Issue 或 PR
+49 -1
View File
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@ def replace_middleware(
**replacement_kwargs,
) -> None:
"""
替换中间件(应在插件的 startup hook 中调用)
替换中间件(应在插件的 setup hook 中调用)
:param app: FastAPI 应用实例
:param original_middleware_cls: 原始中间件类
+14
View File
@@ -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():