Skip to content

Commit 554a6f5

Browse files
authored
Add plugin related interfaces (#535)
* Add plugin related interfaces * Update interface auth * Add build plugin zip service
1 parent 68d41cf commit 554a6f5

File tree

4 files changed

+127
-15
lines changed

4 files changed

+127
-15
lines changed

backend/app/admin/api/v1/sys/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from backend.app.admin.api.v1.sys.dict_data import router as dict_data_router
99
from backend.app.admin.api.v1.sys.dict_type import router as dict_type_router
1010
from backend.app.admin.api.v1.sys.menu import router as menu_router
11+
from backend.app.admin.api.v1.sys.plugin import router as plugin_router
1112
from backend.app.admin.api.v1.sys.role import router as role_router
1213
from backend.app.admin.api.v1.sys.token import router as token_router
1314
from backend.app.admin.api.v1.sys.upload import router as upload_router
@@ -25,3 +26,4 @@
2526
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据权限规则'])
2627
router.include_router(token_router, prefix='/tokens', tags=['系统令牌'])
2728
router.include_router(upload_router, prefix='/upload', tags=['系统上传'])
29+
router.include_router(plugin_router, prefix='/plugin', tags=['系统插件'])
+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import io
4+
import os.path
5+
import zipfile
6+
7+
from typing import Annotated
8+
9+
from fastapi import APIRouter, Depends, File, UploadFile
10+
from fastapi.params import Query
11+
from starlette.responses import StreamingResponse
12+
13+
from backend.common.exception import errors
14+
from backend.common.response.response_schema import ResponseModel, response_base
15+
from backend.common.security.permission import RequestPermission
16+
from backend.common.security.rbac import DependsRBAC
17+
from backend.core.path_conf import PLUGIN_DIR
18+
from backend.plugin.tools import install_requirements_async
19+
20+
router = APIRouter()
21+
22+
23+
@router.post(
24+
'/install',
25+
summary='安装插件',
26+
description='需使用插件 zip 压缩包进行安装',
27+
dependencies=[
28+
Depends(RequestPermission('sys:plugin:install')),
29+
DependsRBAC,
30+
],
31+
)
32+
async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
33+
contents = await file.read()
34+
file_bytes = io.BytesIO(contents)
35+
if not zipfile.is_zipfile(file_bytes):
36+
raise errors.ForbiddenError(msg='插件压缩包格式非法')
37+
with zipfile.ZipFile(file_bytes) as zf:
38+
# 校验压缩包
39+
plugin_dir_in_zip = f'{file.filename[:-4]}/backend/plugin/'
40+
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)]
41+
if not members_in_plugin_dir:
42+
raise errors.ForbiddenError(msg='插件压缩包内容非法')
43+
plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '')
44+
if (
45+
len(members_in_plugin_dir) <= 3
46+
or f'{plugin_dir_in_zip}{plugin_name}/plugin.toml' not in members_in_plugin_dir
47+
or f'{plugin_dir_in_zip}{plugin_name}/README.md' not in members_in_plugin_dir
48+
):
49+
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
50+
51+
# 插件是否可安装
52+
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
53+
if os.path.exists(full_plugin_path):
54+
raise errors.ForbiddenError(msg='此插件已安装')
55+
os.makedirs(full_plugin_path)
56+
57+
# 解压安装
58+
members = []
59+
for member in zf.infolist():
60+
if member.filename.startswith(plugin_dir_in_zip):
61+
member.filename = member.filename.replace(plugin_dir_in_zip, '')
62+
if not member.filename:
63+
continue
64+
members.append(member)
65+
zf.extractall(PLUGIN_DIR, members)
66+
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
67+
await install_requirements_async(False)
68+
69+
return response_base.success()
70+
71+
72+
@router.post(
73+
'/zip',
74+
summary='打包插件',
75+
dependencies=[
76+
Depends(RequestPermission('sys:plugin:zip')),
77+
DependsRBAC,
78+
],
79+
)
80+
async def build_plugin_zip(plugin: Annotated[str, Query()]):
81+
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
82+
if not os.path.exists(plugin_dir):
83+
raise errors.ForbiddenError(msg='插件不存在')
84+
bio = io.BytesIO()
85+
with zipfile.ZipFile(bio, 'w') as zf:
86+
for root, dirs, files in os.walk(plugin_dir):
87+
dirs[:] = [d for d in dirs if d != '__pycache__']
88+
for file in files:
89+
file_path = os.path.join(root, file)
90+
arcname = os.path.relpath(file_path, start=plugin_dir)
91+
zf.write(file_path, arcname)
92+
bio.seek(0)
93+
return StreamingResponse(
94+
bio,
95+
media_type='application/x-zip-compressed',
96+
headers={'Content-Disposition': f'attachment; filename={plugin}.zip'},
97+
)

backend/core/conf.py

+4
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ class Settings(BaseSettings):
178178
'updated_time',
179179
]
180180

181+
# Plugin
182+
PLUGIN_PIP_CHINA: bool = True
183+
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
184+
181185
@model_validator(mode='before')
182186
@classmethod
183187
def check_env(cls, values: Any) -> Any:

backend/plugin/tools.py

+24-15
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from fastapi import APIRouter
1515

16+
from backend.core.conf import settings
1617
from backend.core.path_conf import PLUGIN_DIR
1718
from backend.utils.import_parse import import_module_cached
1819

@@ -156,13 +157,21 @@ def install_requirements() -> None:
156157
else:
157158
try:
158159
subprocess.run([sys.executable, '-m', 'ensurepip', '--upgrade'])
159-
subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-r', requirements_file])
160+
pip_requirements = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file]
161+
if settings.PLUGIN_PIP_CHINA:
162+
pip_requirements.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
163+
subprocess.check_call(pip_requirements)
160164
except subprocess.CalledProcessError as e:
161165
raise PluginInjectError(f'插件 {plugin} 依赖安装失败:{e}') from e
162166

163167

164-
async def install_requirements_async() -> None:
165-
"""异步安装插件依赖"""
168+
async def install_requirements_async(wait: bool = True) -> None:
169+
"""
170+
异步安装插件依赖
171+
172+
:param wait: 是否等待结果并校验,开启将造成 IO 阻塞
173+
:return:
174+
"""
166175
plugins = get_plugins()
167176
for plugin in plugins:
168177
requirements_file = os.path.join(PLUGIN_DIR, plugin, 'requirements.txt')
@@ -177,19 +186,19 @@ async def install_requirements_async() -> None:
177186
stdout=asyncio.subprocess.PIPE,
178187
stderr=asyncio.subprocess.PIPE,
179188
)
180-
_, ensurepip_stderr = await ensurepip_process.communicate()
181-
if ensurepip_process.returncode != 0:
182-
raise PluginInjectError(f'ensurepip 安装失败:{ensurepip_stderr}')
189+
if wait:
190+
_, ensurepip_stderr = await ensurepip_process.communicate()
191+
if ensurepip_process.returncode != 0:
192+
raise PluginInjectError(f'ensurepip 安装失败:{ensurepip_stderr}')
193+
pip_requirements = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file]
194+
if settings.PLUGIN_PIP_CHINA:
195+
pip_requirements.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
183196
pip_process = await async_subprocess.create_subprocess_exec(
184-
sys.executable,
185-
'-m',
186-
'pip',
187-
'install',
188-
'-r',
189-
requirements_file,
197+
*pip_requirements,
190198
stdout=asyncio.subprocess.PIPE,
191199
stderr=asyncio.subprocess.PIPE,
192200
)
193-
_, pip_stderr = await pip_process.communicate()
194-
if pip_process.returncode != 0:
195-
raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{pip_stderr}')
201+
if wait:
202+
_, pip_stderr = await pip_process.communicate()
203+
if pip_process.returncode != 0:
204+
raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{pip_stderr}')

0 commit comments

Comments
 (0)