Refactor code generation and plugin hot reloading (#1032)

* Refactor code generation and plugin hot reloading

* Improve implementation

* Update plugin config loader
This commit is contained in:
Wu Clan
2026-01-23 17:08:51 +08:00
committed by GitHub
parent ced2b8d269
commit a6da0bfd41
7 changed files with 140 additions and 67 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ async def get_all_tables(
@router.post(
'/imports',
summary='导入代码生成业务和模型列',
summary='导入代码生成业务和模型列(仅开发环境)',
dependencies=[
Depends(RequestPermission('codegen:table:import')),
DependsRBAC,
@@ -56,7 +56,7 @@ async def get_generate_paths(
@router.post(
'/{pk}',
summary='代码生成',
description='文件磁盘写入,请谨慎操作',
description='文件磁盘写入,请谨慎操作(仅开发环境)',
dependencies=[
Depends(RequestPermission('codegen:local:write')),
DependsRBAC,
@@ -1,5 +1,7 @@
import io
import os
import shutil
import tempfile
import zipfile
from collections.abc import Sequence
@@ -10,8 +12,10 @@ from anyio import open_file
from pydantic.alias_generators import to_pascal
from sqlalchemy import RowMapping
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from backend.common.exception import errors
from backend.core.conf import settings
from backend.core.path_conf import BASE_PATH
from backend.plugin.code_generator.crud.crud_business import gen_business_dao
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
@@ -24,6 +28,7 @@ from backend.plugin.code_generator.service.column_service import gen_column_serv
from backend.plugin.code_generator.utils.format_code import format_python_code
from backend.plugin.code_generator.utils.gen_template import gen_template
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
from backend.utils.locks import acquire_distributed_reload_lock
class GenService:
@@ -50,6 +55,8 @@ class GenService:
:param obj: 导入参数对象
:return:
"""
if settings.ENVIRONMENT != 'dev':
raise errors.ForbiddenError(msg='禁止在非开发环境下导入代码生成业务')
table_info = await gen_dao.get_table(db, obj.table_schema, obj.table_name)
if not table_info:
@@ -179,27 +186,38 @@ class GenService:
:param pk: 业务 ID
:return:
"""
if settings.ENVIRONMENT != 'dev':
raise errors.ForbiddenError(msg='禁止在非开发环境下生成代码')
business = await gen_business_dao.get(db, pk)
if not business:
raise errors.NotFoundError(msg='业务不存在')
gen_path = business.gen_path or str(BASE_PATH / 'app')
init_files = gen_template.get_init_files(business)
for init_filepath, init_content in init_files.items():
full_path = os.path.join(gen_path, *init_filepath.split('/'))
init_folder = anyio.Path(full_path).parent
await init_folder.mkdir(parents=True, exist_ok=True)
async with await open_file(full_path, 'w', encoding='utf-8') as f:
await f.write(init_content)
async with acquire_distributed_reload_lock():
with tempfile.TemporaryDirectory() as tmp_dir:
all_files = {}
init_files = gen_template.get_init_files(business)
all_files.update(init_files)
rendered_codes = await self._render_tpl_code(db=db, business=business)
all_files.update(rendered_codes)
rendered_codes = await self._render_tpl_code(db=db, business=business)
for code_filepath, code in rendered_codes.items():
full_path = os.path.join(gen_path, *code_filepath.split('/'))
code_folder = anyio.Path(full_path).parent
await code_folder.mkdir(parents=True, exist_ok=True)
async with await open_file(full_path, 'w', encoding='utf-8') as f:
await f.write(code)
for filepath, content in all_files.items():
full_path = os.path.join(tmp_dir, *filepath.split('/'))
code_folder = anyio.Path(full_path).parent
await code_folder.mkdir(parents=True, exist_ok=True)
async with await open_file(full_path, 'w', encoding='utf-8') as f:
await f.write(content)
for item in os.listdir(tmp_dir):
src = os.path.join(tmp_dir, item)
dst = os.path.join(gen_path, item)
src_path = anyio.Path(src)
if await src_path.is_dir():
await run_in_threadpool(shutil.copytree, src, dst, dirs_exist_ok=True)
else:
await run_in_threadpool(shutil.copy2, src, dst)
return gen_path
@@ -215,15 +233,16 @@ class GenService:
if not business:
raise errors.NotFoundError(msg='业务不存在')
all_files = {}
init_files = gen_template.get_init_files(business)
all_files.update(init_files)
rendered_codes = await self._render_tpl_code(db=db, business=business)
all_files.update(rendered_codes)
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:
init_files = gen_template.get_init_files(business)
for init_filepath, init_content in init_files.items():
zf.writestr(init_filepath, init_content)
rendered_codes = await self._render_tpl_code(db=db, business=business)
for code_filepath, code in rendered_codes.items():
zf.writestr(code_filepath, code)
for filepath, content in all_files.items():
zf.writestr(filepath, content)
bio.seek(0)
return bio
+47 -41
View File
@@ -8,6 +8,7 @@ import anyio
from anyio import open_file
from dulwich import porcelain
from fastapi import UploadFile
from starlette.concurrency import run_in_threadpool
from backend.common.exception import errors
from backend.common.log import log
@@ -15,6 +16,7 @@ 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.locks import acquire_distributed_reload_lock
from backend.utils.pattern_validate import is_git_url
@@ -33,43 +35,45 @@ async def install_zip_plugin(file: UploadFile | str) -> str:
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)
async with acquire_distributed_reload_lock():
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='插件压缩包内缺少必要文件')
# 解压(安装
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)
# 插件是否可安装
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)
await install_requirements_async(plugin_dir_name)
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
# 解压(安装)
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)
await run_in_threadpool(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
@@ -88,13 +92,15 @@ async def install_git_plugin(repo_url: str) -> str:
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')
async with acquire_distributed_reload_lock():
try:
await run_in_threadpool(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