当前位置: 首页 > news >正文

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

文章目录

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

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.Nodefunc init() {var err errornode, 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 = truebreak}
}
if !isAllowed {return fmt.Errorf("不支持的文件类型:%s", fileType)
}// 检查文件大小
if req.File.Size > 10*1024*1024 { // 10MBreturn 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 := falsefor _, t := range allowedTypes {if t == fileType {isAllowed = truebreak}}if !isAllowed {liberr.ErrIsNil(ctx, fmt.Errorf("不支持的文件类型:%s", fileType))}// 检查文件大小(如限制20MB)if req.File.Size > 20*1024*1024 {liberr.ErrIsNil(ctx, fmt.Errorf("文件大小不能超过20MB"))}// 计算MD5file, 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.DsVideoInfoerr = dao.DsVideo.Ctx(ctx).Where(dao.DsVideo.Columns().Md5, md5).Scan(&existVideo)liberr.ErrIsNil(ctx, err, "查询视频信息失败")if existVideo != nil {res.Id = existVideo.Idres.Url = fmt.Sprintf("/api/v1/admin/ds/dsVideo/view?id=%d", existVideo.Id)// 获取首帧图片URLimageInfo, 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.DsImageInfoerr = dao.DsImage.Ctx(ctx).Where(dao.DsImage.Columns().Md5, md5).Scan(&existingImage)liberr.ErrIsNil(ctx, err, "查询图片信息失败")var imageId int64if 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 := 0height := 0duration := 0if len(probeData.Streams) > 0 {width = probeData.Streams[0].Widthheight = probeData.Streams[0].Heightif d, err := strconv.ParseFloat(probeData.Streams[0].Duration, 64); err == nil {duration = int(d)}}// 保存视频文件到MinIOvideoObjectName := 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.Idres.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.ResponseWriterwriter.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.ResponseWriterwriter.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软件下载站实战开发(九):编写软件配置管理界面
http://www.dtcms.com/a/265321.html

相关文章:

  • rockchip android14 设置不休眠
  • 数学建模_微分方程
  • 商品中心—18.库存分桶的一致性改造文档
  • RedisCluster不可用的6大隐患
  • 通俗理解JVM细节-面试篇
  • 配置tcp的https协议证书
  • [云上玩转Qwen3系列之四]PAI-LangStudio x AI搜索开放平台 x ElasticSearch: 构建AI Search RAG全栈应用
  • JSON 安装使用教程
  • 新版本没有docker-desktop-data分发 | docker desktop 镜像迁移
  • 用Python实现两种爱心效果❤️
  • 人机协同的智能体开发范式(ADS)
  • HCIA-实现VLAN间通信
  • nrf52840蓝牙学习(定时器的应用)
  • Python 数据分析:numpy,说人话,说说数组维度。听故事学知识点怎么这么容易?
  • 从暴力穷举到智能导航,PC本地搜索被腾讯电脑管家“拯救”
  • 【Vue入门学习笔记】Vue核心语法
  • 百度文心 ERNIE 4.5 开源:开启中国多模态大模型开源新时代
  • MYSQL基础内容
  • 读VJEPA 2
  • Linux Mem -- Slub内存分配器基础
  • 08_Excel 导入 - 用户信息批量导入
  • [ linux-系统 ] 软硬链接与动静态库
  • 基于Java+SpringBoot的图书管理系统
  • 【字节跳动】数据挖掘面试题0002:从转发数据中求原视频用户以及转发的最长深度和二叉排序树指定值
  • Scala 安装使用教程
  • windows系统下将Docker Desktop安装到除了C盘的其它盘中
  • 前端可视化——Canvas实战篇
  • Docker Compose 基础——AI教你学Docker
  • 《寻北技术的全面剖析与应用前景研究报告》
  • 【4】 Deployment深入简出实战演练