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

文章目录

  • [Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥](#Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥)
    • 系统架构图
    • [核心功能设计 🛠️](#核心功能设计 🛠️)
      • [1. 文件上传流程](#1. 文件上传流程)
      • [2. 关键技术实现](#2. 关键技术实现)
        • [2.1 雪花算法](#2.1 雪花算法)
        • [2.2 文件校验机制 ✅](#2.2 文件校验机制 ✅)
        • [2.3 文件去重机制 🔍](#2.3 文件去重机制 🔍)
        • [2.4 视频封面提取 🎞️](#2.4 视频封面提取 🎞️)
        • [2.5 文件存储策略 📂](#2.5 文件存储策略 📂)
        • [2.6 视频上传示例](#2.6 视频上传示例)
      • [3. 文件查看实现 ⬇️](#3. 文件查看实现 ⬇️)

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

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

系统架构图

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

核心功能设计 🛠️

1. 文件上传流程

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

2. 关键技术实现

2.1 雪花算法

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

安装

bash 复制代码
go get -u "github.com/bwmarrin/snowflake"

初始化

go 复制代码
var node *snowflake.Node

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

使用

go 复制代码
id := node.Generate().Int64()
2.2 文件校验机制 ✅
go 复制代码
// 检查文件类型
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值实现文件去重:

go 复制代码
// 计算文件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提取视频首帧作为封面:

go 复制代码
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

代码实现:

go 复制代码
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 视频上传示例
go 复制代码
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格式的元数据,前端根据返回的路径进行接口请求

以视频为例

go 复制代码
// 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软件下载站实战开发(九):编写软件配置管理界面
相关推荐
会编程的土豆2 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪2 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball6163 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
夜悊7 小时前
Go网络编程的学习代码示例:客户端/服务端(C/S)模型
go
Generalzy8 小时前
从本地 Demo 到生产级检索:Milvus 学习笔记(1)
golang·prompt·软件工程
go不是csgo8 小时前
GORM 上手:一个 main.go 跑通 Go 数据库增删改查
jvm·数据库·golang
CCC:CarCrazeCurator9 小时前
Diffusion Transformer(DiT):原理、与 U-Net 对比及在视频生成中的深度应用
人工智能·音视频·transformer
知彼解己10 小时前
RAG 核心实战:检索增强生成
后端·golang·ai编程
子安柠10 小时前
Go语言并发编程:协程与管道详解
开发语言·后端·golang
山楂树の12 小时前
Video核心术语
学习·音视频