feat(deploy): 增加部署文件下载和部署功能

- 新增 DownloadByID 和 DeployByID 方法,实现文件下载和部署
- 优化文件校验逻辑,仅支持 zip 格式
- 添加解压 zip 文件到指定目录的功能
- 修复查询部署项目时关联文件查询的问题
- 优化代码结构,提高可维护性
This commit is contained in:
zhangtao 2025-08-06 14:18:22 +08:00
parent 73492a4add
commit 41a46a42d3
6 changed files with 245 additions and 64 deletions

View File

@ -118,3 +118,31 @@ func (h *SysDeployFileHandler) SetActiveFile(c *gin.Context) {
logger.Info(c, "设置活跃文件", zap.String("id", c.Param("id")))
c.JSON(200, h.deployFileService.SetActiveFile(c))
}
// DownloadByID 根据ID下载文件
// @Summary 下载文件
// @Description 根据ID下载部署文件
// @Tags 部署文件管理
// @Accept json
// @Produce application/octet-stream
// @Param id path string true "文件ID"
// @Success 200 {file} file "文件内容"
// @Router /deploy-files/{id}/download [get]
func (h *SysDeployFileHandler) DownloadByID(c *gin.Context) {
logger.Info(c, "下载文件", zap.String("id", c.Param("id")))
h.deployFileService.DownloadByID(c)
}
// DeployByID 根据ID部署文件
// @Summary 部署文件
// @Description 根据ID部署部署文件
// @Tags 部署文件管理
// @Accept json
// @Produce json
// @Param id path string true "文件ID"
// @Success 200 {object} serializer.Response
// @Router /deploy-files/{id}/deploy [put]
func (h *SysDeployFileHandler) DeployByID(c *gin.Context) {
logger.Info(c, "部署文件", zap.String("id", c.Param("id")))
c.JSON(200, h.deployFileService.DeployByID(c))
}

View File

@ -4,24 +4,25 @@ import "time"
// SysDeployProject 部署项目记录表
type SysDeployProject struct {
DeployId string `gorm:"column:deploy_id;type:varchar(64);primary_key;comment:部署ID" json:"deployId"`
ProjectName string `gorm:"column:project_name;type:varchar(100);not null;comment:项目名称" json:"projectName" binding:"required"`
Domain string `gorm:"column:domain;type:varchar(255);not null;comment:访问域名" json:"domain" binding:"required"`
DeployPath string `gorm:"column:deploy_path;type:varchar(500);not null;comment:部署路径" json:"deployPath"`
FileId string `gorm:"-" json:"fileId" binding:"required"`
Status string `gorm:"column:status;type:char(1);default:1;comment:状态0停用 1正常 2部署中 3部署失败" json:"status"`
DeployStatus string `gorm:"column:deploy_status;type:char(1);default:0;comment:部署状态0未部署 1部署成功 2部署失败" json:"deployStatus"`
ErrorMsg string `gorm:"column:error_msg;type:text;comment:错误信息" json:"errorMsg"`
Version string `gorm:"column:version;type:varchar(50);comment:版本号" json:"version"`
Description string `gorm:"column:description;type:varchar(500);comment:描述" json:"description"`
DelFlag string `gorm:"column:del_flag;type:char(1);default:0;comment:删除标志0代表存在 1代表删除" json:"delFlag"`
CreateBy string `gorm:"column:create_by;type:varchar(64);comment:创建者" json:"createBy"`
CreateTime *time.Time `gorm:"column:create_time;type:datetime;comment:创建时间" json:"createTime"`
UpdateBy string `gorm:"column:update_by;type:varchar(64);comment:更新者" json:"updateBy"`
UpdateTime *time.Time `gorm:"column:update_time;type:datetime;comment:更新时间" json:"updateTime"`
DeployTime *time.Time `gorm:"column:deploy_time;type:datetime;comment:部署时间" json:"deployTime"`
LastAccessTime *time.Time `gorm:"column:last_access_time;type:datetime;comment:最后访问时间" json:"lastAccessTime"`
AccessCount int64 `gorm:"column:access_count;type:bigint;default:0;comment:访问次数" json:"accessCount"`
DeployId string `gorm:"column:deploy_id;type:varchar(64);primary_key;comment:部署ID" json:"deployId"`
ProjectName string `gorm:"column:project_name;type:varchar(100);not null;comment:项目名称" json:"projectName" binding:"required"`
Domain string `gorm:"column:domain;type:varchar(255);not null;comment:访问域名" json:"domain" binding:"required"`
DeployPath string `gorm:"column:deploy_path;type:varchar(500);not null;comment:部署路径" json:"deployPath"`
FileId string `gorm:"-" json:"fileId" binding:"required"`
Status string `gorm:"column:status;type:char(1);default:1;comment:状态0停用 1正常 2部署中 3部署失败" json:"status"`
DeployStatus string `gorm:"column:deploy_status;type:char(1);default:0;comment:部署状态0未部署 1部署成功 2部署失败" json:"deployStatus"`
ErrorMsg string `gorm:"column:error_msg;type:text;comment:错误信息" json:"errorMsg"`
Version string `gorm:"column:version;type:varchar(50);comment:版本号" json:"version"`
Description string `gorm:"column:description;type:varchar(500);comment:描述" json:"description"`
DelFlag string `gorm:"column:del_flag;type:char(1);default:0;comment:删除标志0代表存在 1代表删除" json:"delFlag"`
CreateBy string `gorm:"column:create_by;type:varchar(64);comment:创建者" json:"createBy"`
CreateTime *time.Time `gorm:"column:create_time;type:datetime;comment:创建时间" json:"createTime"`
UpdateBy string `gorm:"column:update_by;type:varchar(64);comment:更新者" json:"updateBy"`
UpdateTime *time.Time `gorm:"column:update_time;type:datetime;comment:更新时间" json:"updateTime"`
DeployTime *time.Time `gorm:"column:deploy_time;type:datetime;comment:部署时间" json:"deployTime"`
LastAccessTime *time.Time `gorm:"column:last_access_time;type:datetime;comment:最后访问时间" json:"lastAccessTime"`
AccessCount int64 `gorm:"column:access_count;type:bigint;default:0;comment:访问次数" json:"accessCount"`
SysDeployFiles []*SysDeployFile `gorm:"-" json:"sysDeployFiles"`
}
// TableName 表名

View File

@ -33,4 +33,6 @@ func SysDeployFileHandlerRouter(group *gin.RouterGroup, h *handler.SysDeployFile
g.GET("", h.GetByCondition)
g.GET("/parent/:parentId", h.GetByParentID)
g.PUT("/:id/active", h.SetActiveFile)
g.GET("/:id/download", h.DownloadByID)
g.PUT("/:id/deploy", h.DeployByID)
}

View File

@ -1,6 +1,7 @@
package service
import (
"archive/zip"
"crypto/md5"
"ego/internal/model"
"ego/internal/serializer"
@ -9,13 +10,12 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
@ -41,24 +41,21 @@ func (s *SysDeployFileService) Create(c *gin.Context) serializer.Response {
}
// 校验文件类型
supportedExtensions := []string{".zip", ".tar", ".gz"}
valid := false
for _, ext := range supportedExtensions {
if strings.HasSuffix(strings.ToLower(file.Filename), ext) {
valid = true
break
}
}
if !valid {
logger.Error(c, "只支持zip、tar、gz格式文件!")
return serializer.ParamErr("只支持zip、tar、gz格式文件!", nil)
if !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
logger.Error(c, "只支持zip格式文件!")
return serializer.ParamErr("只支持zip格式文件!", nil)
}
// 获取文件名(不包含扩展名)
filename := strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename))
var deployFile model.SysDeployFile
// 设置文件名
deployFile.FileName = filename
// 获取项目id
deployFile.ParentId = c.PostForm("parentId")
// 获取文件扩展名
fileExt := filepath.Ext(file.Filename)
@ -75,10 +72,10 @@ func (s *SysDeployFileService) Create(c *gin.Context) serializer.Response {
}
// 校验文件
err = s.ValidateArchiveFile(deployFile.FilePath, fileExt, file.Filename)
if err != nil {
logger.Error(c, "校验文件失败!")
return serializer.ParamErr("校验文件失败!", err)
zipFile, err := s.validateZipFile(deployFile.FilePath)
if !zipFile {
logger.Error(c, "文件格式错误!")
return serializer.ParamErr("文件格式错误!", nil)
}
// 生成文件ID
@ -381,41 +378,184 @@ func (s *SysDeployFileService) CalculateFileHash(reader io.Reader) (string, erro
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
// ValidateArchiveFile 校验压缩文件是否包含 index.html
func (s *SysDeployFileService) ValidateArchiveFile(filePath, fileExt, originalFilename string) error {
// 只在linux下检查
if runtime.GOOS != "linux" {
return nil // 非Linux系统跳过校验
// DownloadByID 根据ID下载文件
func (s *SysDeployFileService) DownloadByID(c *gin.Context) serializer.Response {
// 获取当前用户ID
currentUserId := c.GetString("id")
if currentUserId == "" {
return serializer.ParamErr("用户信息获取失败!", nil)
}
var cmd *exec.Cmd
switch fileExt {
case ".zip":
cmd = exec.Command("unzip", "-l", filePath)
case ".tar":
cmd = exec.Command("tar", "-tf", filePath)
case ".gz":
// 对于 .tar.gz 文件,需要特殊处理
if strings.HasSuffix(strings.ToLower(originalFilename), ".tar.gz") {
cmd = exec.Command("tar", "-tzf", filePath)
} else {
// 单独的 .gz 文件不支持列表查看
// 这里假设是 .tar.gz 文件
cmd = exec.Command("tar", "-tzf", filePath)
}
default:
return fmt.Errorf("不支持的文件格式")
id := c.Param("id")
if id == "" {
logger.Error(c, "id 不可为空!")
return serializer.ParamErr("id不可为空!", fmt.Errorf("id不可为空"))
}
output, err := cmd.Output()
// 获取文件信息
var deployFile model.SysDeployFile
if err := s.Db.Where("file_id = ? AND del_flag = ? AND create_by = ?", id, "0", currentUserId).First(&deployFile).Error; err != nil {
logger.Error(c, "获取部署文件记录失败!")
return serializer.DBErr("获取部署文件记录失败!", err)
}
// 检查文件是否存在
if _, err := os.Stat(deployFile.FilePath); os.IsNotExist(err) {
logger.Error(c, "文件不存在!")
return serializer.ParamErr("文件不存在!", err)
}
// 设置响应头,提供文件下载
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", "attachment; filename="+deployFile.FileName+filepath.Ext(deployFile.FilePath))
c.Header("Content-Type", "application/octet-stream")
// 提供文件下载
c.File(deployFile.FilePath)
return serializer.Response{Code: 200}
}
// DeployByID 部署文件
func (s *SysDeployFileService) DeployByID(c *gin.Context) serializer.Response {
logger.Info(c, "部署文件", zap.String("id", c.Param("id")))
// 获取当前用户ID
currentUserId := c.GetString("id")
if currentUserId == "" {
return serializer.ParamErr("用户信息获取失败!", nil)
}
id := c.Param("id")
if id == "" {
logger.Error(c, "id 不可为空!")
return serializer.ParamErr("id不可为空!", fmt.Errorf("id不可为空"))
}
// 获取文件信息
var deployFile model.SysDeployFile
if err := s.Db.Where("file_id = ? AND del_flag = ? AND create_by = ?", id, "0", currentUserId).First(&deployFile).Error; err != nil {
logger.Error(c, "获取部署文件记录失败!")
return serializer.DBErr("获取部署文件记录失败!", err)
}
// 检查文件是否存在
if _, err := os.Stat(deployFile.FilePath); os.IsNotExist(err) {
logger.Error(c, "文件不存在!")
return serializer.ParamErr("文件不存在!", err)
}
var deployProject model.SysDeployProject
if err := s.Db.Where("deploy_id = ? AND del_flag = ? AND create_by = ?", deployFile.ParentId, "0", currentUserId).First(&deployProject).Error; err != nil {
logger.Error(c, "获取部署项目记录失败!")
return serializer.DBErr("获取部署项目记录失败!", err)
}
err := s.extractZip(deployFile.FilePath, deployProject.DeployPath)
if err != nil {
return fmt.Errorf("检查文件内容失败: %v", err)
logger.Error(c, "解压文件失败!")
return serializer.DBErr("解压文件失败!", err)
}
// 检查输出中是否包含 index.html
outputStr := string(output)
if !strings.Contains(outputStr, "index.html") {
return fmt.Errorf("压缩包中必须包含 index.html 文件")
// 开始事务
tx := s.Db.Begin()
// 更新状态
if tx.Model(&model.SysDeployFile{}).Where("file_id = ?", id).Updates(map[string]any{
"status": model.DeployStatusSuccess,
"update_time": time.Now(),
"update_by": currentUserId,
}).Error != nil {
tx.Rollback()
logger.Error(c, "更新部署文件状态失败!")
return serializer.DBErr("更新部署文件状态失败!", err)
}
// 更新状态
if tx.Model(&model.SysDeployProject{}).Where("deploy_id = ?", deployFile.ParentId).Updates(map[string]any{
"deploy_status": model.DeployStatusSuccess,
"deploy_time": time.Now(),
"update_time": time.Now(),
"update_by": currentUserId,
}).Error != nil {
tx.Rollback()
logger.Error(c, "更新部署项目状态失败!")
return serializer.DBErr("更新部署项目状态失败!", err)
}
tx.Commit()
return serializer.Succ("部署成功!", nil)
}
// validateZipFile 校验压缩包是否包含index.html
func (s *SysDeployFileService) validateZipFile(zipPath string) (bool, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return false, err
}
defer reader.Close()
for _, file := range reader.File {
if strings.ToLower(filepath.Base(file.Name)) == "index.html" {
return true, nil
}
}
return false, nil
}
// extractZip 解压zip文件到指定目录
func (s *SysDeployFileService) extractZip(zipPath, destDir string) error {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer reader.Close()
// 创建目标目录
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// 提取文件
for _, file := range reader.File {
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
// 构建文件路径
path := filepath.Join(destDir, file.Name)
// 检查路径是否安全(防止路径遍历攻击)
if !strings.HasPrefix(path, filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("invalid file path: %s", file.Name)
}
if file.FileInfo().IsDir() {
// 创建目录
if err := os.MkdirAll(path, file.FileInfo().Mode()); err != nil {
return err
}
} else {
// 创建文件的父目录
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
// 创建文件
outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
if err != nil {
return err
}
defer outFile.Close()
// 复制文件内容
if _, err := io.Copy(outFile, rc); err != nil {
return err
}
}
}
return nil

View File

@ -276,6 +276,15 @@ func (s *SysDeployProjectService) GetByCondition(c *gin.Context) serializer.Resp
return serializer.DBErr("获取部署项目记录失败!", err)
}
// 查询每个项目的关联文件
for i := range deployProjects {
var deployFiles []*model.SysDeployFile
s.Db.Where("parent_id = ? AND del_flag = ? AND create_by = ?",
deployProjects[i].DeployId, "0", currentUserId).Find(&deployFiles)
// 这里需要在 model.SysDeployProject 中添加 SysDeployFiles 字段
deployProjects[i].SysDeployFiles = deployFiles
}
// 执行总数查询
if err := db.Where("del_flag = ?", "0").Count(&total).Error; err != nil {
logger.Error(c, "获取部署项目记录总数失败!")

View File

@ -6,6 +6,7 @@ import (
"ego/internal/model"
"ego/internal/serializer"
"ego/pkg/logger"
"errors"
"fmt"
"io"
"os"
@ -114,7 +115,7 @@ func (s *SysUploadService) UploadZip(c *gin.Context) serializer.Response {
// 检查是否已存在相同域名的项目
err = s.Db.Where("domain = ? AND del_flag = ?", domain, "0").First(&deployProject).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 创建新项目
deployProject = model.SysDeployProject{
ProjectName: filename,