Skip to content

Commit 1e54b0b

Browse files
authored
Add plugin info config and interfaces (#601)
* Add plugin info config and interfaces * Remove test data
1 parent 27b9cf4 commit 1e54b0b

File tree

17 files changed

+538
-146
lines changed

17 files changed

+538
-146
lines changed

.pre-commit-config.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
33
rev: v5.0.0
44
hooks:
5-
- id: check-added-large-files
6-
- id: end-of-file-fixer
5+
# - id: check-added-large-files
6+
# - id: end-of-file-fixer
77
- id: check-yaml
88
- id: check-toml
99

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env python3
1+
# !/usr/bin/env python3
22
# -*- coding: utf-8 -*-
33
from fastapi import APIRouter
44

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

+55-60
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,79 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3-
import io
4-
import os.path
5-
import zipfile
6-
7-
from typing import Annotated
3+
from typing import Annotated, Any
84

95
from fastapi import APIRouter, Depends, File, UploadFile
106
from fastapi.params import Query
117
from starlette.responses import StreamingResponse
128

13-
from backend.common.exception import errors
14-
from backend.common.response.response_schema import ResponseModel, response_base
9+
from backend.app.admin.service.plugin_service import plugin_service
10+
from backend.common.response.response_code import CustomResponseCode
11+
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
12+
from backend.common.security.jwt import DependsJwtAuth
1513
from backend.common.security.permission import RequestPermission
1614
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
1915

2016
router = APIRouter()
2117

2218

19+
@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth])
20+
async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]:
21+
plugins = await plugin_service.get_all()
22+
return response_base.success(data=plugins)
23+
24+
25+
@router.post(
26+
'/install/zip',
27+
summary='安装 zip 插件',
28+
description='使用插件 zip 压缩包进行安装',
29+
dependencies=[
30+
Depends(RequestPermission('sys:plugin:install')),
31+
DependsRBAC,
32+
],
33+
)
34+
async def install_zip_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
35+
await plugin_service.install_zip(file=file)
36+
return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
37+
38+
2339
@router.post(
24-
'/install',
25-
summary='安装插件',
26-
description='需使用插件 zip 压缩包进行安装',
40+
'/install/git',
41+
summary='安装 git 插件',
42+
description='使用插件 git 仓库地址进行安装,不限制平台;如果需要凭证,需在 git 仓库地址中添加凭证信息',
2743
dependencies=[
2844
Depends(RequestPermission('sys:plugin:install')),
2945
DependsRBAC,
3046
],
3147
)
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='插件压缩包内缺少必要文件')
48+
async def install_git_plugin(repo_url: Annotated[str, Query(description='插件 git 仓库地址')]) -> ResponseModel:
49+
await plugin_service.install_git(repo_url=repo_url)
50+
return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS)
51+
5052

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-
else:
56-
os.makedirs(full_plugin_path, exist_ok=True)
53+
@router.post(
54+
'/uninstall',
55+
summary='卸载插件',
56+
description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录',
57+
dependencies=[
58+
Depends(RequestPermission('sys:plugin:uninstall')),
59+
DependsRBAC,
60+
],
61+
)
62+
async def uninstall_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel:
63+
await plugin_service.uninstall(plugin=plugin)
64+
return response_base.success(res=CustomResponseCode.PLUGIN_UNINSTALL_SUCCESS)
5765

58-
# 解压(安装)
59-
members = []
60-
for member in zf.infolist():
61-
if member.filename.startswith(plugin_dir_in_zip):
62-
new_filename = member.filename.replace(plugin_dir_in_zip, '')
63-
if new_filename:
64-
member.filename = new_filename
65-
members.append(member)
66-
zf.extractall(PLUGIN_DIR, members)
67-
if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')):
68-
await install_requirements_async()
6966

67+
@router.post(
68+
'/status',
69+
summary='更新插件状态',
70+
dependencies=[
71+
Depends(RequestPermission('sys:plugin:status')),
72+
DependsRBAC,
73+
],
74+
)
75+
async def update_plugin_status(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel:
76+
await plugin_service.update_status(plugin=plugin)
7077
return response_base.success()
7178

7279

@@ -79,19 +86,7 @@ async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel:
7986
],
8087
)
8188
async def build_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> StreamingResponse:
82-
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
83-
if not os.path.exists(plugin_dir):
84-
raise errors.ForbiddenError(msg='插件不存在')
85-
86-
bio = io.BytesIO()
87-
with zipfile.ZipFile(bio, 'w') as zf:
88-
for root, dirs, files in os.walk(plugin_dir):
89-
dirs[:] = [d for d in dirs if d != '__pycache__']
90-
for file in files:
91-
file_path = os.path.join(root, file)
92-
arcname = os.path.relpath(file_path, start=plugin_dir)
93-
zf.write(file_path, arcname)
94-
89+
bio = await plugin_service.build(plugin=plugin)
9590
bio.seek(0)
9691
return StreamingResponse(
9792
bio,
+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import io
4+
import json
5+
import os
6+
import shutil
7+
import zipfile
8+
9+
from typing import Any
10+
11+
from dulwich import porcelain
12+
from fastapi import UploadFile
13+
14+
from backend.common.enums import StatusType
15+
from backend.common.exception import errors
16+
from backend.common.log import log
17+
from backend.core.conf import settings
18+
from backend.core.path_conf import PLUGIN_DIR
19+
from backend.database.redis import redis_client
20+
from backend.plugin.tools import install_requirements_async, uninstall_requirements_async
21+
from backend.utils.re_verify import is_git_url
22+
from backend.utils.timezone import timezone
23+
24+
25+
class PluginService:
26+
"""插件服务类"""
27+
28+
@staticmethod
29+
async def get_all() -> list[dict[str, Any]]:
30+
"""获取所有插件"""
31+
keys = []
32+
result = []
33+
34+
async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'):
35+
keys.append(key)
36+
37+
for info in await redis_client.mget(*keys):
38+
result.append(json.loads(info))
39+
40+
return result
41+
42+
@staticmethod
43+
async def install_zip(*, file: UploadFile) -> None:
44+
"""
45+
通过 zip 压缩包安装插件
46+
47+
:param file: 插件 zip 压缩包
48+
:return:
49+
"""
50+
contents = await file.read()
51+
file_bytes = io.BytesIO(contents)
52+
if not zipfile.is_zipfile(file_bytes):
53+
raise errors.ForbiddenError(msg='插件压缩包格式非法')
54+
with zipfile.ZipFile(file_bytes) as zf:
55+
# 校验压缩包
56+
plugin_dir_in_zip = file.filename[:-4]
57+
members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)]
58+
if not members_in_plugin_dir:
59+
raise errors.ForbiddenError(msg='插件压缩包内容非法')
60+
plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '')
61+
if (
62+
len(members_in_plugin_dir) <= 3
63+
or f'{plugin_dir_in_zip}/plugin.toml' not in members_in_plugin_dir
64+
or f'{plugin_dir_in_zip}/README.md' not in members_in_plugin_dir
65+
):
66+
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
67+
68+
# 插件是否可安装
69+
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
70+
if os.path.exists(full_plugin_path):
71+
raise errors.ForbiddenError(msg='此插件已安装')
72+
else:
73+
os.makedirs(full_plugin_path, exist_ok=True)
74+
75+
# 解压(安装)
76+
members = []
77+
for member in zf.infolist():
78+
if member.filename.startswith(plugin_dir_in_zip):
79+
new_filename = member.filename.replace(plugin_dir_in_zip, '')
80+
if new_filename:
81+
member.filename = new_filename
82+
members.append(member)
83+
zf.extractall(PLUGIN_DIR, members)
84+
85+
await install_requirements_async(plugin_name)
86+
87+
@staticmethod
88+
async def install_git(*, repo_url: str):
89+
"""
90+
通过 git 安装插件
91+
92+
:param repo_url: git 存储库的 URL
93+
:return:
94+
"""
95+
match = is_git_url(repo_url)
96+
if not match:
97+
raise errors.ForbiddenError(msg='Git 仓库地址格式非法')
98+
repo_name = match.group('repo')
99+
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
100+
if repo_name in plugins:
101+
raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装')
102+
try:
103+
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
104+
except Exception as e:
105+
log.error(f'插件安装失败: {e}')
106+
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
107+
else:
108+
await install_requirements_async(repo_name)
109+
110+
@staticmethod
111+
async def uninstall(*, plugin: str):
112+
"""
113+
卸载插件
114+
115+
:param plugin: 插件名称
116+
:return:
117+
"""
118+
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
119+
if not os.path.exists(plugin_dir):
120+
raise errors.ForbiddenError(msg='插件不存在')
121+
await uninstall_requirements_async(plugin)
122+
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
123+
shutil.move(plugin_dir, bacup_dir)
124+
125+
@staticmethod
126+
async def update_status(*, plugin: str):
127+
"""
128+
更新插件状态
129+
130+
:param plugin: 插件名称
131+
:return:
132+
"""
133+
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
134+
if not plugin_info:
135+
raise errors.ForbiddenError(msg='插件不存在')
136+
plugin_info = json.loads(plugin_info)
137+
new_status = (
138+
StatusType.enable.value
139+
if plugin_info.get('plugin', {}).get('enable') == StatusType.disable.value
140+
else StatusType.disable.value
141+
)
142+
plugin_info['plugin']['enable'] = new_status
143+
await redis_client.set(
144+
f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False)
145+
)
146+
await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, str(new_status))
147+
148+
@staticmethod
149+
async def build(*, plugin: str) -> io.BytesIO:
150+
"""
151+
打包插件为 zip 压缩包
152+
153+
:param plugin: 插件名称
154+
:return:
155+
"""
156+
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
157+
if not os.path.exists(plugin_dir):
158+
raise errors.ForbiddenError(msg='插件不存在')
159+
160+
bio = io.BytesIO()
161+
with zipfile.ZipFile(bio, 'w') as zf:
162+
for root, dirs, files in os.walk(plugin_dir):
163+
dirs[:] = [d for d in dirs if d != '__pycache__']
164+
for file in files:
165+
file_path = os.path.join(root, file)
166+
arcname = os.path.relpath(file_path, start=plugin_dir)
167+
zf.write(file_path, arcname)
168+
169+
return bio
170+
171+
172+
plugin_service: PluginService = PluginService()

backend/common/response/response_code.py

+4
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class CustomResponseCode(CustomCodeBase):
3939
HTTP_503 = (503, '服务器暂时无法处理请求')
4040
HTTP_504 = (504, '网关超时')
4141

42+
# Plugin
43+
PLUGIN_INSTALL_SUCCESS = (200, '插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务')
44+
PLUGIN_UNINSTALL_SUCCESS = (200, '插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
45+
4246

4347
class CustomErrorCode(CustomCodeBase):
4448
"""自定义错误状态码"""

backend/core/conf.py

+4-5
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,10 @@ class Settings(BaseSettings):
181181
'confirm_password',
182182
]
183183

184-
# 插件配置
184+
# Plugin 配置
185185
PLUGIN_PIP_CHINA: bool = True
186186
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
187+
PLUGIN_REDIS_PREFIX: str = 'fba:plugin'
187188

188189
# App Admin
189190
# .env OAuth2
@@ -200,11 +201,11 @@ class Settings(BaseSettings):
200201
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
201202

202203
# App Task
203-
# .env Redis 配置
204+
# .env Redis
204205
CELERY_BROKER_REDIS_DATABASE: int
205206
CELERY_BACKEND_REDIS_DATABASE: int
206207

207-
# .env RabbitMQ 配置
208+
# .env RabbitMQ
208209
# docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest
209210
CELERY_RABBITMQ_HOST: str
210211
CELERY_RABBITMQ_PORT: int
@@ -238,11 +239,9 @@ class Settings(BaseSettings):
238239
}
239240

240241
# Plugin Code Generator
241-
# 代码下载
242242
CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str = 'fba_generator'
243243

244244
# Plugin Config
245-
# 参数配置
246245
CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy']
247246

248247
@model_validator(mode='before')
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1+
[plugin]
2+
summary = '代码生成'
3+
version = '0.0.1'
4+
description = '生成通用业务代码'
5+
author = 'wu-clan'
6+
17
[app]
28
router = ['v1']

0 commit comments

Comments
 (0)