feat(skills): add skill file upload API endpoint

Add new POST endpoint /api/v1/skills/upload for uploading skill zip files.
The endpoint:
- Accepts zip files with bot_id parameter
- Validates file format (must be .zip)
- Saves zip to projects/uploads/{bot_id}/skill_zip/
- Automatically extracts to projects/uploads/{bot_id}/skills/{skill_name}/
- Returns success response with file and extract paths

This enables programmatic skill deployment for specific bots.
This commit is contained in:
朱潮 2026-01-07 14:47:25 +08:00
parent 1233bdda0c
commit 92c82c24a4

View File

@ -1,6 +1,7 @@
import os import os
import uuid import uuid
import shutil import shutil
import zipfile
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, HTTPException, Header, UploadFile, File, Form from fastapi import APIRouter, HTTPException, Header, UploadFile, File, Form
@ -271,7 +272,7 @@ async def cleanup_project_async_endpoint(dataset_id: str, remove_all: bool = Fal
async def upload_file(file: UploadFile = File(...), folder: Optional[str] = Form(None)): async def upload_file(file: UploadFile = File(...), folder: Optional[str] = Form(None)):
""" """
文件上传API接口上传文件到 ./projects/uploads/ 目录下 文件上传API接口上传文件到 ./projects/uploads/ 目录下
可以指定自定义文件夹名如果不指定则使用日期文件夹 可以指定自定义文件夹名如果不指定则使用日期文件夹
指定文件夹时使用原始文件名并支持版本控制 指定文件夹时使用原始文件名并支持版本控制
@ -286,18 +287,16 @@ async def upload_file(file: UploadFile = File(...), folder: Optional[str] = Form
# 调试信息 # 调试信息
logger.info(f"Received folder parameter: {folder}") logger.info(f"Received folder parameter: {folder}")
logger.info(f"File received: {file.filename if file else 'None'}") logger.info(f"File received: {file.filename if file else 'None'}")
# 确定上传文件夹 # 确定上传文件夹
if folder: if folder:
# 使用指定的自定义文件夹 # 使用指定的自定义文件夹
target_folder = folder target_folder = folder
# 安全性检查:防止路径遍历攻击
target_folder = os.path.basename(target_folder) target_folder = os.path.basename(target_folder)
else: else:
# 获取当前日期并格式化为年月日 # 获取当前日期并格式化为年月日
current_date = datetime.now() current_date = datetime.now()
target_folder = current_date.strftime("%Y%m%d") target_folder = current_date.strftime("%Y%m%d")
# 创建上传目录 # 创建上传目录
upload_dir = os.path.join("projects", "uploads", target_folder) upload_dir = os.path.join("projects", "uploads", target_folder)
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
@ -312,7 +311,6 @@ async def upload_file(file: UploadFile = File(...), folder: Optional[str] = Form
# 根据是否指定文件夹决定命名策略 # 根据是否指定文件夹决定命名策略
if folder: if folder:
# 使用原始文件名,支持版本控制
final_filename, version = get_versioned_filename(upload_dir, name_without_ext, file_extension) final_filename, version = get_versioned_filename(upload_dir, name_without_ext, file_extension)
file_path = os.path.join(upload_dir, final_filename) file_path = os.path.join(upload_dir, final_filename)
@ -352,6 +350,82 @@ async def upload_file(file: UploadFile = File(...), folder: Optional[str] = Form
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}") raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
@router.post("/api/v1/skills/upload")
async def upload_skills(file: UploadFile = File(...), bot_id: Optional[str] = Form(None)):
"""
Skill文件上传API接口上传zip文件到 ./projects/uploads/ 目录下并自动解压
如果folder参数包含"skills"且文件是.zip格式保存后会自动解压到同名目录
Args:
file: 上传的zip文件
folder: 可选的自定义文件夹名 "skills" "projects/uploads/xxx/skills"
Returns:
dict: 包含文件路径解压信息的响应
"""
try:
# 调试信息
logger.info(f"Skill upload - bot_id parameter: {bot_id}")
logger.info(f"File received: {file.filename if file else 'None'}")
# 验证文件名
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
# 验证是否为zip文件
original_filename = file.filename
name_without_ext, file_extension = os.path.splitext(original_filename)
if file_extension.lower() != '.zip':
raise HTTPException(status_code=400, detail="仅支持上传.zip格式的skill文件")
folder_name = name_without_ext
# 创建上传目录
upload_dir = os.path.join("projects", "uploads", bot_id, "skill_zip")
extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
os.makedirs(extract_target, exist_ok=True)
os.makedirs(upload_dir, exist_ok=True)
try:
# 保存zip文件使用原始文件名
file_path = os.path.join(upload_dir, original_filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
logger.info(f"Saved zip file: {file_path}")
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(extract_target)
logger.info(f"Extracted to: {extract_target}")
return {
"success": True,
"message": f"Skill文件上传并解压成功",
"file_path": file_path,
"extract_path": extract_target,
"original_filename": original_filename,
"skill_name": folder_name
}
except zipfile.BadZipFile:
# 解压失败删除已保存的zip文件
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=400, detail="上传的文件不是有效的zip文件")
except Exception as e:
# 解压失败删除已保存的zip文件
if os.path.exists(file_path):
os.remove(file_path)
logger.error(f"解压失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"解压失败: {str(e)}")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error uploading skill file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Skill文件上传失败: {str(e)}")
# Task management routes that are related to file processing # Task management routes that are related to file processing
@router.get("/api/v1/task/{task_id}/status") @router.get("/api/v1/task/{task_id}/status")
async def get_task_status(task_id: str): async def get_task_status(task_id: str):