Softhub软件下载站实战开发(十):实现图片视频上传下载接口

发布于:2025-07-05 ⋅ 阅读:(13) ⋅ 点赞:(0)

Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥

在上一篇文章中,我们实现了软件配置面板,实现了ai配置信息的存储,为后续富文本编辑器的ai功能提供了基础,本文致力于解决在富文本编辑器中图片和视频的上传查看功能。

系统架构图

上传文件
下载文件
读取
客户端
API接口
文件处理层
存储服务
MinIO存储
数据库
MySQL

核心功能设计 🛠️

1. 文件上传流程

客户端 服务端 MinIO 数据库 上传文件请求 验证文件类型和大小 计算文件MD5 检查文件是否已存在 返回已存在记录 直接返回文件URL 上传文件到MinIO 返回成功 保存文件元信息 返回成功 返回文件URL alt [文件已存在] [文件不存在] 客户端 服务端 MinIO 数据库

2. 关键技术实现

2.1 雪花算法

关键数据不能采取自增id方案,采用md5也会有碰撞和页分裂的问题,这里采用雪花算法来解决这一问题

安装

go get -u "github.com/bwmarrin/snowflake"

初始化

var node *snowflake.Node

func init() {
	var err error
	node, err = snowflake.NewNode(1)
}

使用

id := node.Generate().Int64()
2.2 文件校验机制 ✅
// 检查文件类型
fileType := strings.ToLower(filepath.Ext(req.File.Filename))
allowedTypes := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
isAllowed := false
for _, t := range allowedTypes {
    if t == fileType {
        isAllowed = true
        break
    }
}
if !isAllowed {
    return fmt.Errorf("不支持的文件类型:%s", fileType)
}

// 检查文件大小
if req.File.Size > 10*1024*1024 { // 10MB
    return fmt.Errorf("文件大小不能超过10MB")
}
2.3 文件去重机制 🔍

通过计算文件MD5值实现文件去重:

// 计算文件MD5
fileBytes, _ := io.ReadAll(file)
md5 := gmd5.MustEncryptBytes(fileBytes)

// 检查是否已存在
var existFile *model.DsImageInfo
err = dao.DsImage.Ctx(ctx).Where(dao.DsImage.Columns().Md5, md5).Scan(&existFile)
if existFile != nil {
    // 直接返回已有文件信息
    return existFile, nil
}
2.4 视频封面提取 🎞️

需要ffmpeg添加到环境变量中

使用FFmpeg提取视频首帧作为封面:

cmd := exec.Command("ffmpeg",
    "-y",                 // 覆盖输出文件
    "-loglevel", "error", // 只输出错误信息
    "-i", tempVideoPath,  // 输入文件
    "-vframes", "1",      // 只提取一帧
    "-an",                // 不处理音频
    "-vf", "scale='-1:min(720,ih)'", // 限制最大高度为720
    "-c:v", "mjpeg",      // 使用mjpeg编码器
    "-f", "image2",       // 输出格式
    "-q:v", "2",          // 高质量输出
    tempFramePath)        // 输出文件
2.5 文件存储策略 📂

采用分层目录结构存储文件:

pic/
  2024/
    05/
      07/
        abc123def456.pic
video/
  2024/
    05/
      07/
        xyz789uvw012.video

代码实现:

now := gtime.Now()
year := now.Year()
month := int(now.Month())
day := now.Day()
objectName := fmt.Sprintf("pic/%d/%02d/%02d/%s.pic", year, month, day, md5)
2.6 视频上传示例
func (s *sDsIUpload) VideoUpload(ctx context.Context, req *api.DsVideoUploadReq) (res *api.DsVideoUploadRes, err error) {
	res = &api.DsVideoUploadRes{}
	err = g.Try(ctx, func(ctx context.Context) {
		// 检查文件类型
		fileType := strings.ToLower(filepath.Ext(req.File.Filename))
		allowedTypes := []string{".mp4", ".avi", ".mov", ".mkv"}
		isAllowed := false
		for _, t := range allowedTypes {
			if t == fileType {
				isAllowed = true
				break
			}
		}
		if !isAllowed {
			liberr.ErrIsNil(ctx, fmt.Errorf("不支持的文件类型:%s", fileType))
		}

		// 检查文件大小(如限制20MB)
		if req.File.Size > 20*1024*1024 {
			liberr.ErrIsNil(ctx, fmt.Errorf("文件大小不能超过20MB"))
		}

		// 计算MD5
		file, err := req.File.Open()
		liberr.ErrIsNil(ctx, err, "打开文件失败")
		defer file.Close()
		fileBytes, err := io.ReadAll(file)
		liberr.ErrIsNil(ctx, err, "读取文件失败")
		md5 := gmd5.MustEncryptBytes(fileBytes)

		// 检查是否已存在
		var existVideo *model.DsVideoInfo
		err = dao.DsVideo.Ctx(ctx).Where(dao.DsVideo.Columns().Md5, md5).Scan(&existVideo)
		liberr.ErrIsNil(ctx, err, "查询视频信息失败")
		if existVideo != nil {
			res.Id = existVideo.Id
			res.Url = fmt.Sprintf("/api/v1/admin/ds/dsVideo/view?id=%d", existVideo.Id)
			// 获取首帧图片URL
			imageInfo, err := s.GetImageInfo(ctx, &api.DsImageInfoReq{Id: existVideo.PosterId})
			if err == nil && imageInfo != nil {
				res.Poster = fmt.Sprintf("/api/v1/admin/ds/dsImage/view?id=%d", imageInfo.Id)
			}
			return
		}

		// 创建临时目录
		tempDir := filepath.Join(os.TempDir(), "upload", md5)
		if _, err := os.Stat(tempDir); os.IsNotExist(err) {
			err = os.MkdirAll(tempDir, 0755)
			liberr.ErrIsNil(ctx, err, "创建临时目录失败")
		}

		// 生成临时文件路径
		tempVideoPath := filepath.Join(tempDir, fmt.Sprintf("video%s", fileType))
		tempFramePath := filepath.Join(tempDir, "frame.jpg")

		g.Log().Debugf(ctx, "临时视频文件路径: %s", tempVideoPath)
		g.Log().Debugf(ctx, "临时帧图片路径: %s", tempFramePath)

		// 保存视频到临时文件
		file.Seek(0, 0)
		tempFile, err := os.OpenFile(tempVideoPath, os.O_WRONLY|os.O_CREATE, 0644)
		liberr.ErrIsNil(ctx, err, "创建临时文件失败")
		_, err = io.Copy(tempFile, file)
		tempFile.Close()
		liberr.ErrIsNil(ctx, err, "保存临时文件失败")

		// 确保临时文件存在且可读
		if _, err := os.Stat(tempVideoPath); err != nil {
			liberr.ErrIsNil(ctx, fmt.Errorf("临时视频文件不存在或无法访问: %v", err))
		}

		// 使用ffmpeg提取首帧
		cmd := exec.Command("ffmpeg",
			"-y",                 // 覆盖输出文件
			"-loglevel", "error", // 只输出错误信息
			"-i", tempVideoPath, // 输入文件
			"-vframes", "1", // 只提取一帧
			"-an",                           // 不处理音频
			"-vf", "scale='-1:min(720,ih)'", // 限制最大高度为720,保持宽高比
			"-c:v", "mjpeg", // 使用 mjpeg 编码器
			"-f", "image2", // 输出格式
			"-q:v", "2", // 高质量输出
			tempFramePath) // 输出文件
		output, err := cmd.CombinedOutput()
		if err != nil {
			// 清理临时文件
			os.RemoveAll(tempDir)
			liberr.ErrIsNil(ctx, fmt.Errorf("提取视频首帧失败: %v, 输出: %s", err, string(output)))
		}

		// 获取MinIO客户端
		drive := storage.MinioDrive{}
		client, err := drive.GetClient()
		liberr.ErrIsNil(ctx, err, "获取MinIO客户端失败")

		// 生成存储路径
		now := gtime.Now()
		year := now.Year()
		month := int(now.Month())
		day := now.Day()
		frameObjectName := fmt.Sprintf("pic/%d/%02d/%02d/%s.jpg", year, month, day, md5)

		// 读取首帧图片
		frameFile, err := os.Open(tempFramePath)
		liberr.ErrIsNil(ctx, err, "打开首帧图片失败")
		defer frameFile.Close()

		// 获取首帧图片信息
		frameInfo, err := frameFile.Stat()
		liberr.ErrIsNil(ctx, err, "获取首帧图片信息失败")

		// 检查是否已存在相同MD5的图片
		var existingImage *model.DsImageInfo
		err = dao.DsImage.Ctx(ctx).Where(dao.DsImage.Columns().Md5, md5).Scan(&existingImage)
		liberr.ErrIsNil(ctx, err, "查询图片信息失败")

		var imageId int64
		if existingImage != nil {
			// 使用已存在的图片记录
			imageId = existingImage.Id
		} else {
			// 获取图片尺寸
			frameFile.Seek(0, 0)
			img, _, err := image.DecodeConfig(frameFile)
			if err != nil {
				g.Log().Warningf(ctx, "获取图片尺寸失败: %v", err)
			}

			// 重新定位到文件开始位置用于上传
			frameFile.Seek(0, 0)

			// 上传首帧图片到MinIO
			_, err = client.PutObject(ctx, config.MINIO_BUCKET, frameObjectName, frameFile, frameInfo.Size(), minio.PutObjectOptions{
				ContentType: "image/jpeg",
			})
			liberr.ErrIsNil(ctx, err, "上传首帧图片失败")

			// 保存首帧图片信息
			imageInfo := &model.DsImageInfo{
				Id:        node.Generate().Int64(),
				Md5:       md5,
				Name:      fmt.Sprintf("%s_frame.jpg", req.File.Filename),
				Path:      frameObjectName,
				Size:      frameInfo.Size(),
				MimeType:  "image/jpeg",
				Width:     img.Width,
				Height:    img.Height,
				CreatedBy: 0,
				CreatedAt: gtime.Now(),
				UpdatedBy: 0,
				UpdatedAt: gtime.Now(),
			}

			// 保存首帧图片信息到数据库
			_, err = dao.DsImage.Ctx(ctx).Insert(imageInfo)
			liberr.ErrIsNil(ctx, err, "保存首帧图片信息失败")
			imageId = imageInfo.Id
		}

		// 获取视频元数据
		cmd = exec.Command("ffprobe",
			"-v", "quiet",
			"-print_format", "json",
			"-show_format",
			"-show_streams",
			tempVideoPath)
		output, err = cmd.Output()
		liberr.ErrIsNil(ctx, err, "获取视频信息失败")

		var probeData struct {
			Streams []struct {
				Width    int    `json:"width"`
				Height   int    `json:"height"`
				Duration string `json:"duration"`
			} `json:"streams"`
		}
		err = json.Unmarshal(output, &probeData)
		liberr.ErrIsNil(ctx, err, "解析视频信息失败")

		width := 0
		height := 0
		duration := 0
		if len(probeData.Streams) > 0 {
			width = probeData.Streams[0].Width
			height = probeData.Streams[0].Height
			if d, err := strconv.ParseFloat(probeData.Streams[0].Duration, 64); err == nil {
				duration = int(d)
			}
		}

		// 保存视频文件到MinIO
		videoObjectName := fmt.Sprintf("video/%d/%02d/%02d/%s.video", year, month, day, md5)
		file.Seek(0, 0)
		err = drive.UploadWithPath(ctx, req.File, videoObjectName)
		liberr.ErrIsNil(ctx, err, "保存文件失败")

		// 保存视频信息
		videoInfo := &model.DsVideoInfo{
			Id:        node.Generate().Int64(),
			PosterId:  imageId,
			Md5:       md5,
			Name:      req.File.Filename,
			Path:      videoObjectName,
			Size:      req.File.Size,
			MimeType:  req.File.Header.Get("Content-Type"),
			Duration:  duration,
			Width:     width,
			Height:    height,
			CreatedBy: 0,
			CreatedAt: gtime.Now(),
			UpdatedBy: 0,
			UpdatedAt: gtime.Now(),
		}
		_, err = dao.DsVideo.Ctx(ctx).Insert(videoInfo)
		liberr.ErrIsNil(ctx, err, "保存视频信息失败")

		// 清理临时目录
		os.RemoveAll(tempDir)

		res.Id = videoInfo.Id
		res.Url = fmt.Sprintf("/api/v1/admin/ds/dsVideo/view?id=%d", videoInfo.Id)
		res.Poster = fmt.Sprintf("/api/v1/admin/ds/dsImage/view?id=%d", imageId)
	})
	return
}

3. 文件查看实现 ⬇️

获取文件信息:返回JSON格式的元数据,前端根据返回的路径进行接口请求

以视频为例

// GetVideoInfo 获取视频信息
func (c *dsUploadController) GetVideoInfo(ctx context.Context, req *api.DsVideoInfoReq) (res *api.DsVideoInfoRes, err error) {
	// 查询视频信息
	videoInfo, err := service.DsUpload().GetVideoInfo(ctx, req)
	if err != nil {
		return nil, err
	}
	// 直接从 MinIO 读取视频内容
	drive := storage.MinioDrive{}
	client, err := drive.GetClient()
	if err != nil {
		return nil, err
	}
	obj, err := client.GetObject(ctx, config.MINIO_BUCKET, videoInfo.Path, minio.GetObjectOptions{})
	if err != nil {
		return nil, err
	}
	defer obj.Close()

	// 设置响应头
	writer := g.RequestFromCtx(ctx).Response.ResponseWriter
	writer.Header().Set("Content-Type", videoInfo.MimeType)
	writer.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", videoInfo.Name))
	// 写入视频流
	_, err = io.Copy(writer, obj)
	return nil, err // 不返回JSON
}

// ViewVideo 返回视频二进制流
func (c *dsUploadController) ViewVideo(ctx context.Context, req *api.DsVideoViewReq) (res *api.DsVideoViewRes, err error) {
	// 查询视频信息
	videoInfo, err := service.DsUpload().GetVideoInfo(ctx, &api.DsVideoInfoReq{Id: req.Id})
	if err != nil {
		return nil, err
	}
	// 直接从 MinIO 读取视频内容
	drive := storage.MinioDrive{}
	client, err := drive.GetClient()
	if err != nil {
		return nil, err
	}
	obj, err := client.GetObject(ctx, config.MINIO_BUCKET, videoInfo.Path, minio.GetObjectOptions{})
	if err != nil {
		return nil, err
	}
	defer obj.Close()

	// 设置响应头
	writer := g.RequestFromCtx(ctx).Response.ResponseWriter
	writer.Header().Set("Content-Type", videoInfo.MimeType)
	writer.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", videoInfo.Name))
	// 写入视频流
	_, err = io.Copy(writer, obj)
	return nil, err // 不返回JSON
}

softhub系列往期文章

  1. Softhub软件下载站实战开发(一):项目总览
  2. Softhub软件下载站实战开发(二):项目基础框架搭建
  3. Softhub软件下载站实战开发(三):平台管理模块实战
  4. Softhub软件下载站实战开发(四):代码生成器设计与实现
  5. Softhub软件下载站实战开发(五):分类模块实现
  6. Softhub软件下载站实战开发(六):软件配置面板实现
  7. Softhub软件下载站实战开发(七):集成MinIO实现文件存储功能
  8. Softhub软件下载站实战开发(八):编写软件后台管理
  9. Softhub软件下载站实战开发(九):编写软件配置管理界面