From e988cbca91bdd97871665a42a589d56e5704c170 Mon Sep 17 00:00:00 2001 From: CaptainB Date: Tue, 9 Sep 2025 10:32:59 +0800 Subject: [PATCH] feat: add Appstore tool retrieval and store tool API endpoint --- .../migrations/0003_alter_tool_template_id.py | 23 +++ apps/tools/models/tool.py | 3 +- apps/tools/serializers/tool.py | 140 +++++++++++++++++- apps/tools/sql/list_tool.sql | 2 + apps/tools/sql/list_tool_user.sql | 2 + apps/tools/sql/list_tool_user_ee.sql | 2 + apps/tools/urls.py | 3 + apps/tools/views/tool.py | 81 ++++++++++ ui/src/api/system-shared/tool.ts | 22 ++- ui/src/api/tool/store.ts | 23 +++ ui/src/api/tool/tool.ts | 23 +++ ui/src/locales/lang/en-US/views/tool.ts | 2 + ui/src/locales/lang/zh-CN/views/tool.ts | 2 + ui/src/locales/lang/zh-Hant/views/tool.ts | 2 + .../ToolResourceIndex.vue | 1 + .../tool/component/ToolListContainer.vue | 67 +++++++++ .../views/tool/toolStore/ToolStoreDialog.vue | 80 +++++++++- 17 files changed, 468 insertions(+), 10 deletions(-) create mode 100644 apps/tools/migrations/0003_alter_tool_template_id.py diff --git a/apps/tools/migrations/0003_alter_tool_template_id.py b/apps/tools/migrations/0003_alter_tool_template_id.py new file mode 100644 index 000000000..58df7cebc --- /dev/null +++ b/apps/tools/migrations/0003_alter_tool_template_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2025-09-09 04:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tools', '0002_alter_tool_tool_type'), + ] + + operations = [ + migrations.AlterField( + model_name='tool', + name='template_id', + field=models.CharField(db_index=True, default=None, max_length=128, null=True, verbose_name='模版id'), + ), + migrations.AddField( + model_name='tool', + name='version', + field=models.CharField(default=None, max_length=64, null=True, verbose_name='版本号'), + ), + ] diff --git a/apps/tools/models/tool.py b/apps/tools/models/tool.py index 41918b2aa..e58eb14ef 100644 --- a/apps/tools/models/tool.py +++ b/apps/tools/models/tool.py @@ -48,11 +48,12 @@ class Tool(AppModelMixin): default=ToolScope.WORKSPACE, db_index=True) tool_type = models.CharField(max_length=20, verbose_name='工具类型', choices=ToolType.choices, default=ToolType.CUSTOM, db_index=True) - template_id = models.UUIDField(max_length=128, verbose_name="模版id", null=True, default=None, db_index=True) + template_id = models.CharField(max_length=128, verbose_name="模版id", null=True, default=None, db_index=True) folder = models.ForeignKey(ToolFolder, on_delete=models.DO_NOTHING, verbose_name="文件夹id", default='default') workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default", db_index=True) init_params = models.CharField(max_length=102400, verbose_name="初始化参数", null=True) label = models.CharField(max_length=128, verbose_name="标签", null=True, db_index=True) + version = models.CharField(max_length=64, verbose_name="版本号", null=True, default=None) class Meta: db_table = "tool" diff --git a/apps/tools/serializers/tool.py b/apps/tools/serializers/tool.py index 3fa256531..e012c5252 100644 --- a/apps/tools/serializers/tool.py +++ b/apps/tools/serializers/tool.py @@ -5,6 +5,9 @@ import json import os import pickle import re +import requests +import tempfile +import zipfile from typing import Dict import uuid_utils.compat as uuid @@ -124,7 +127,7 @@ class ToolModelSerializer(serializers.ModelSerializer): model = Tool fields = ['id', 'name', 'icon', 'desc', 'code', 'input_field_list', 'init_field_list', 'init_params', 'scope', 'is_active', 'user_id', 'template_id', 'workspace_id', 'folder_id', 'tool_type', 'label', - 'create_time', 'update_time'] + 'version', 'create_time', 'update_time'] class ToolExportModelSerializer(serializers.ModelSerializer): @@ -705,6 +708,7 @@ class ToolSerializer(serializers.Serializer): tool_type=ToolType.CUSTOM, folder_id=instance.get('folder_id', self.data.get('workspace_id')), template_id=internal_tool.id, + label=internal_tool.label, is_active=False ) tool.save() @@ -718,6 +722,140 @@ class ToolSerializer(serializers.Serializer): return ToolModelSerializer(tool).data + class StoreTool(serializers.Serializer): + user_id = serializers.UUIDField(required=True, label=_("User ID")) + name = serializers.CharField(required=False, label=_("tool name"), allow_null=True, allow_blank=True) + + def get_appstore_tools(self): + self.is_valid(raise_exception=True) + # 下载zip文件 + try: + res = requests.get('https://apps-assets.fit2cloud.com/stable/maxkb.json.zip', timeout=5) + res.raise_for_status() + # 创建临时文件保存zip + with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_zip: + temp_zip.write(res.content) + temp_zip_path = temp_zip.name + + try: + # 解压zip文件 + with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: + # 获取zip中的第一个文件(假设只有一个json文件) + json_filename = zip_ref.namelist()[0] + json_content = zip_ref.read(json_filename) + + # 将json转换为字典 + tool_store = json.loads(json_content.decode('utf-8')) + tag_dict = {tag['name']: tag['key'] for tag in tool_store['additionalProperties']['tags']} + filter_apps = [] + for tool in tool_store['apps']: + if self.data.get('name', '') != '': + if self.data.get('name').lower() not in tool.get('name', '').lower(): + continue + versions = tool.get('versions', []) + tool['label'] = tag_dict[tool.get('tags')[0]] if tool.get('tags') else '' + tool['version'] = next( + (version.get('name') for version in versions if version.get('downloadUrl') == tool['downloadUrl']), + ) + filter_apps.append(tool) + + tool_store['apps'] = filter_apps + return tool_store + finally: + # 清理临时文件 + os.unlink(temp_zip_path) + except requests.RequestException as e: + maxkb_logger.error(f"fetch appstore tools error: {e}") + return [] + + class AddStoreTool(serializers.Serializer): + user_id = serializers.UUIDField(required=True, label=_("User ID")) + workspace_id = serializers.CharField(required=True, label=_("workspace id")) + tool_id = serializers.CharField(required=True, label=_("tool id")) + + def add(self, instance: Dict, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + AddInternalToolRequest(data=instance).is_valid(raise_exception=True) + + versions = instance.get('versions', []) + download_url = instance.get('download_url') + # 查找匹配的版本名称 + version_name = next( + (version.get('name') for version in versions if version.get('downloadUrl') == download_url), + ) + res = requests.get(download_url, timeout=5) + tool_data = RestrictedUnpickler(io.BytesIO(res.content)).load().tool + tool_id = uuid.uuid7() + tool = Tool( + id=tool_id, + name=tool_data.get('name'), + desc=tool_data.get('desc'), + code=tool_data.get('code'), + user_id=self.data.get('user_id'), + icon=instance.get('icon', ''), + workspace_id=self.data.get('workspace_id'), + input_field_list=tool_data.get('input_field_list', []), + init_field_list=tool_data.get('init_field_list', []), + scope=ToolScope.WORKSPACE, + tool_type=ToolType.CUSTOM, + folder_id=instance.get('folder_id', self.data.get('workspace_id')), + template_id=self.data.get('tool_id'), + label=instance.get('label'), + version=version_name, + is_active=False + ) + tool.save() + + # 自动授权给创建者 + UserResourcePermissionSerializer(data={ + 'workspace_id': self.data.get('workspace_id'), + 'user_id': self.data.get('user_id'), + 'auth_target_type': AuthTargetType.TOOL.value + }).auth_resource(str(tool_id)) + try: + requests.get(instance.get('download_callback_url'), timeout=5) + except Exception as e: + maxkb_logger.error(f"callback appstore tool download error: {e}") + return ToolModelSerializer(tool).data + + class UpdateStoreTool(serializers.Serializer): + user_id = serializers.UUIDField(required=True, label=_("User ID")) + workspace_id = serializers.CharField(required=True, label=_("workspace id")) + tool_id = serializers.UUIDField(required=True, label=_("tool id")) + download_url = serializers.CharField(required=True, label=_("download url")) + download_callback_url = serializers.CharField(required=True, label=_("download callback url")) + icon = serializers.CharField(required=True, label=_("icon"), allow_null=True, allow_blank=True) + versions = serializers.ListField(required=True, label=_("versions"), child=serializers.DictField()) + + def update_tool(self, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + tool = QuerySet(Tool).filter(id=self.data.get('tool_id')).first() + if tool is None: + raise AppApiException(500, _('Tool does not exist')) + # 查找匹配的版本名称 + version_name = next( + (version.get('name') for version in self.data.get('versions') if version.get('downloadUrl') == self.data.get('download_url')), + ) + res = requests.get(self.data.get('download_url'), timeout=5) + tool_data = RestrictedUnpickler(io.BytesIO(res.content)).load().tool + tool.name = tool_data.get('name') + tool.desc = tool_data.get('desc') + tool.code = tool_data.get('code') + tool.input_field_list = tool_data.get('input_field_list', []) + tool.init_field_list = tool_data.get('init_field_list', []) + tool.icon = self.data.get('icon', tool.icon) + tool.version = version_name + # tool.is_active = False + tool.save() + try: + requests.get(self.data.get('download_callback_url'), timeout=5) + except Exception as e: + maxkb_logger.error(f"callback appstore tool download error: {e}") + return ToolModelSerializer(tool).data + + class ToolTreeSerializer(serializers.Serializer): class Query(serializers.Serializer): diff --git a/apps/tools/sql/list_tool.sql b/apps/tools/sql/list_tool.sql index 978042813..eb583fe53 100644 --- a/apps/tools/sql/list_tool.sql +++ b/apps/tools/sql/list_tool.sql @@ -16,6 +16,7 @@ from (select tool."id"::text, tool."update_time", tool.init_field_list, tool.input_field_list, + tool.version, tool."is_active" from tool left join "user" on "user".id = user_id ${tool_query_set} @@ -37,6 +38,7 @@ from (select tool."id"::text, tool_folder."update_time", '[]'::jsonb as init_field_list, '[]'::jsonb as input_field_list, + '' as version, 'true' as "is_active" from tool_folder left join "user" on "user".id = user_id ${folder_query_set}) temp diff --git a/apps/tools/sql/list_tool_user.sql b/apps/tools/sql/list_tool_user.sql index 7a39c73b5..25476f366 100644 --- a/apps/tools/sql/list_tool_user.sql +++ b/apps/tools/sql/list_tool_user.sql @@ -16,6 +16,7 @@ FROM (SELECT tool."id"::text, tool."update_time", tool.init_field_list, tool.input_field_list, + tool.version, tool."is_active" FROM (SELECT tool.* FROM tool tool ${tool_query_set} @@ -43,6 +44,7 @@ FROM (SELECT tool."id"::text, tool_folder."update_time", '[]'::jsonb AS init_field_list, '[]'::jsonb AS input_field_list, + '' AS version, 'true' AS "is_active" FROM tool_folder LEFT JOIN "user" ON "user".id = user_id ${folder_query_set}) temp diff --git a/apps/tools/sql/list_tool_user_ee.sql b/apps/tools/sql/list_tool_user_ee.sql index 00c7b808a..39c3b9f3b 100644 --- a/apps/tools/sql/list_tool_user_ee.sql +++ b/apps/tools/sql/list_tool_user_ee.sql @@ -16,6 +16,7 @@ FROM (SELECT tool."id"::text, tool."update_time", tool.init_field_list, tool.input_field_list, + tool.version, tool."is_active" FROM (SELECT tool.* FROM tool tool ${tool_query_set} @@ -53,6 +54,7 @@ FROM (SELECT tool."id"::text, tool_folder."update_time", '[]'::jsonb AS init_field_list, '[]'::jsonb AS input_field_list, + '' AS version, 'true' AS "is_active" FROM tool_folder LEFT JOIN "user" ON "user".id = user_id ${folder_query_set}) temp diff --git a/apps/tools/urls.py b/apps/tools/urls.py index b62d0d675..7890a7885 100644 --- a/apps/tools/urls.py +++ b/apps/tools/urls.py @@ -6,6 +6,7 @@ app_name = "tool" # @formatter:off urlpatterns = [ path('workspace/internal/tool', views.ToolView.InternalTool.as_view()), + path('workspace/store/tool', views.ToolView.StoreTool.as_view()), path('workspace//tool', views.ToolView.as_view()), path('workspace//tool/import', views.ToolView.Import.as_view()), path('workspace//tool/pylint', views.ToolView.Pylint.as_view()), @@ -15,5 +16,7 @@ urlpatterns = [ path('workspace//tool//edit_icon', views.ToolView.EditIcon.as_view()), path('workspace//tool//export', views.ToolView.Export.as_view()), path('workspace//tool//add_internal_tool', views.ToolView.AddInternalTool.as_view()), + path('workspace//tool//add_store_tool', views.ToolView.AddStoreTool.as_view()), + path('workspace//tool//update_store_tool', views.ToolView.UpdateStoreTool.as_view()), path('workspace//tool//', views.ToolView.Page.as_view()), ] diff --git a/apps/tools/views/tool.py b/apps/tools/views/tool.py index 6d8f794f1..645cfacb5 100644 --- a/apps/tools/views/tool.py +++ b/apps/tools/views/tool.py @@ -407,3 +407,84 @@ class ToolView(APIView): 'user_id': request.user.id, 'workspace_id': workspace_id }).add(request.data)) + + class StoreTool(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get Appstore tools"), + summary=_("Get Appstore tools"), + operation_id=_("Get Appstore tools"), # type: ignore + responses=GetInternalToolAPI.get_response(), + tags=[_("Tool")] # type: ignore + ) + def get(self, request: Request): + return result.success(ToolSerializer.StoreTool(data={ + 'user_id': request.user.id, + 'name': request.query_params.get('name', ''), + }).get_appstore_tools()) + + class AddStoreTool(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=['POST'], + description=_("Add Appstore tool"), + summary=_("Add Appstore tool"), + operation_id=_("Add Appstore tool"), # type: ignore + parameters=AddInternalToolAPI.get_parameters(), + request=AddInternalToolAPI.get_request(), + responses=AddInternalToolAPI.get_response(), + tags=[_("Tool")] # type: ignore + ) + @has_permissions( + PermissionConstants.TOOL_CREATE.get_workspace_permission(), + PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + RoleConstants.USER.get_workspace_role(), + ) + @log( + menu='Tool', operate="Add Appstore tool", + get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')), + ) + def post(self, request: Request, tool_id: str, workspace_id: str): + return result.success(ToolSerializer.AddStoreTool(data={ + 'tool_id': tool_id, + 'user_id': request.user.id, + 'workspace_id': workspace_id, + }).add(request.data)) + + class UpdateStoreTool(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=['POST'], + description=_("Update Appstore tool"), + summary=_("Update Appstore tool"), + operation_id=_("Update Appstore tool"), # type: ignore + parameters=AddInternalToolAPI.get_parameters(), + request=AddInternalToolAPI.get_request(), + responses=AddInternalToolAPI.get_response(), + tags=[_("Tool")] # type: ignore + ) + @has_permissions( + PermissionConstants.TOOL_CREATE.get_workspace_permission(), + PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + RoleConstants.USER.get_workspace_role(), + ) + @log( + menu='Tool', operate="Update Appstore tool", + get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')), + ) + def post(self, request: Request, tool_id: str, workspace_id: str): + return result.success(ToolSerializer.UpdateStoreTool(data={ + 'tool_id': tool_id, + 'user_id': request.user.id, + 'workspace_id': workspace_id, + 'download_url': request.data.get('download_url'), + 'download_callback_url': request.data.get('download_callback_url'), + 'icon': request.data.get('icon'), + 'versions': request.data.get('versions'), + }).update_tool(request.data)) \ No newline at end of file diff --git a/ui/src/api/system-shared/tool.ts b/ui/src/api/system-shared/tool.ts index 17fcb053a..4275b8a1e 100644 --- a/ui/src/api/system-shared/tool.ts +++ b/ui/src/api/system-shared/tool.ts @@ -142,6 +142,24 @@ const addInternalTool: ( return post(`${prefix}/${tool_id}/add_internal_tool`, param, undefined, loading) } +/** + * 工具商店 + */ +const addStoreTool: ( + tool_id: string, + param: AddInternalToolParam, + loading?: Ref, +) => Promise> = (tool_id, param, loading) => { + return post(`${prefix}/${tool_id}/add_store_tool`, param, undefined, loading) +} + +const updateStoreTool: ( + tool_id: string, + param: AddInternalToolParam, + loading?: Ref, +) => Promise> = (tool_id, param, loading) => { + return post(`${prefix}/${tool_id}/update_store_tool`, param, undefined, loading) +} export default { getToolList, @@ -156,5 +174,7 @@ export default { exportTool, putToolIcon, delTool, - addInternalTool + addInternalTool, + addStoreTool, + updateStoreTool } diff --git a/ui/src/api/tool/store.ts b/ui/src/api/tool/store.ts index ef3407728..5494770da 100644 --- a/ui/src/api/tool/store.ts +++ b/ui/src/api/tool/store.ts @@ -22,6 +22,16 @@ const getInternalToolList: (param?: any, loading?: Ref) => Promise) => Promise> = ( + param, + loading, +) => { + return get('/workspace/store/tool', param, loading) +} + /** * 工具商店-添加系统内置 */ @@ -33,7 +43,20 @@ const addInternalTool: ( return post(`${prefix.value}/${tool_id}/add_internal_tool`, param, undefined, loading) } +/** + * 工具商店-添加 + */ +const addStoreTool: ( + tool_id: string, + param: AddInternalToolParam, + loading?: Ref, +) => Promise> = (tool_id, param, loading) => { + return post(`${prefix.value}/${tool_id}/add_store_tool`, param, undefined, loading) +} + export default { getInternalToolList, + getStoreToolList, addInternalTool, + addStoreTool } diff --git a/ui/src/api/tool/tool.ts b/ui/src/api/tool/tool.ts index f03982345..c67cae1de 100644 --- a/ui/src/api/tool/tool.ts +++ b/ui/src/api/tool/tool.ts @@ -148,6 +148,27 @@ const addInternalTool: ( return post(`${prefix.value}/${tool_id}/add_internal_tool`, param, undefined, loading) } + +/** + * 工具商店-添加 + */ +const addStoreTool: ( + tool_id: string, + param: AddInternalToolParam, + loading?: Ref, +) => Promise> = (tool_id, param, loading) => { + return post(`${prefix.value}/${tool_id}/add_store_tool`, param, undefined, loading) +} + +const updateStoreTool: ( + tool_id: string, + param: AddInternalToolParam, + loading?: Ref, +) => Promise> = (tool_id, param, loading) => { + return post(`${prefix.value}/${tool_id}/update_store_tool`, param, undefined, loading) +} + + export default { getToolList, getAllToolList, @@ -162,4 +183,6 @@ export default { putToolIcon, delTool, addInternalTool, + addStoreTool, + updateStoreTool } diff --git a/ui/src/locales/lang/en-US/views/tool.ts b/ui/src/locales/lang/en-US/views/tool.ts index 146cf1cbd..0d28ed3f4 100644 --- a/ui/src/locales/lang/en-US/views/tool.ts +++ b/ui/src/locales/lang/en-US/views/tool.ts @@ -20,6 +20,8 @@ export default { developer: 'Developer', communication: 'Communication', searchResult: '{count} search results for', + confirmTip: 'Are you sure to update tool: ', + updateStoreToolMessage: 'Updating tools may affect resources in use, so proceed with caution.', }, searchBar: { placeholder: 'Search by tool name', diff --git a/ui/src/locales/lang/zh-CN/views/tool.ts b/ui/src/locales/lang/zh-CN/views/tool.ts index 7a2dc4379..7446a1f55 100644 --- a/ui/src/locales/lang/zh-CN/views/tool.ts +++ b/ui/src/locales/lang/zh-CN/views/tool.ts @@ -20,6 +20,8 @@ export default { developer: '开发者', communication: '通信', searchResult: '的搜索结果 {count} 个', + confirmTip: '是否更新工具:', + updateStoreToolMessage: '更新工具可能会影响正在使用的资源,请谨慎操作。', }, delete: { confirmTitle: '是否刪除工具', diff --git a/ui/src/locales/lang/zh-Hant/views/tool.ts b/ui/src/locales/lang/zh-Hant/views/tool.ts index 59f9d1259..4a28f84fc 100644 --- a/ui/src/locales/lang/zh-Hant/views/tool.ts +++ b/ui/src/locales/lang/zh-Hant/views/tool.ts @@ -20,6 +20,8 @@ export default { developer: '開發者', communication: '通信', searchResult: '的搜索結果 {count} 個', + confirmTip: '是否更新工具:', + updateStoreToolMessage: '更新工具可能會影響正在使用的資源,請謹慎操作。', }, searchBar: { placeholder: '按工具名稱搜尋', diff --git a/ui/src/views/system-resource-management/ToolResourceIndex.vue b/ui/src/views/system-resource-management/ToolResourceIndex.vue index 3ae8927c5..832df86ce 100644 --- a/ui/src/views/system-resource-management/ToolResourceIndex.vue +++ b/ui/src/views/system-resource-management/ToolResourceIndex.vue @@ -72,6 +72,7 @@ +