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软件下载站实战开发(九):编写软件配置管理界面
相关推荐
天天进步20155 小时前
Pixelle-Video 源码解析 #15:AI 视频片段生成:从静态配图到动态短视频
人工智能·音视频
美狐美颜SDK开放平台5 小时前
直播APP开发核心功能详解:美颜SDK、音视频、礼物等功能成本怎么计算?
android·人工智能·计算机视觉·音视频·直播美颜sdk
2601_962100735 小时前
手绘医学科普视频制作教程(2026 年)
音视频
汝裕光电6 小时前
上海酒店宴会厅舞台 LED 大屏音视频集成改造实操要点,婚宴 / 年会 / 论坛不停业改造落地指南
音视频
白搞电子6 小时前
ESP-Mosaico 掌上游戏开发:LVGL 抛硬币动画、音频混音与 BMI270 摇晃触发
单片机·嵌入式硬件·物联网·游戏·音视频
安河桥。6 小时前
V4L2架构在工程实践中的应用
嵌入式硬件·音视频
qq_339191146 小时前
go cpu占比高排查,cpu100%排查,go pprof cpu命令
开发语言·后端·golang
idcu7 小时前
CodeSchema 开源首发:一个给 AI 编码助手「喂」精准代码上下文的索引服务
开源·go·ai编程
Likeadust7 小时前
一键创建,说开就开:私有化音视频系统EasyDSS视频会议的入会、协作与AI纪要全流程
人工智能·音视频·easydss
2601_962100737 小时前
2026年健康科普短视频怎么拍:手机拍摄规范与开源半自动工作流复盘
智能手机·开源·音视频