当前位置: 首页 > 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.lryc.cn/news/579285.html

相关文章:

  • 全角半角空格在网页中占位符和编码emsp;ensp;
  • CentOS 6操作系统安装
  • 毫米波雷达 – 深度学习
  • ubuntu 22.04 LTS 安装preempt-rt
  • C++2d我的世界V1.4
  • 模型预测专题:强鲁棒性DPCC
  • YOLOv11剪枝与量化(二)通道剪枝技术原理
  • Dify 工作流全栈解析:从零构建你的 AI 应用流程引擎
  • 【Java面试】Redis的poll函数epoll函数区别?
  • springboot 显示打印加载bean耗时工具类
  • 【大模型学习 | MINIGPT-4原理】
  • MYSQL基础内容
  • dial tcp 10.1.68.88:3306: connect: cannot assign requested address
  • Python 数据分析:numpy,说人话,说说数组维度。听故事学知识点怎么这么容易?
  • 深度剖析NumPy核心函数reshape()
  • 使用Scapy构造OSPF交互报文欺骗网络设备与主机建立Full关系
  • Python 高光谱分析工具(PyHAT)
  • 【Linux】不小心又创建了一个root权限账户,怎么将它删除?!
  • 数据结构与算法:贪心(二)
  • Docker Compose 基础——AI教你学Docker
  • 鸿蒙UI框架深度解析:对比Android/iOS的布局适配与组件设计
  • 优雅草蜻蜓T语音会议系统私有化部署方案与RTC技术深度解析-优雅草卓伊凡|clam
  • 【字节跳动】数据挖掘面试题0002:从转发数据中求原视频用户以及转发的最长深度和二叉排序树指定值
  • gin框架 中间件 是在判断路由存在前执行还是存在后执行的研究
  • 人工智能-基础篇-14-知识库和知识图谱介绍(知识库是基石、知识图谱是增强语义理解的知识库、结构化数据和非结构化数据区分)
  • ubentu服务器版本安装Dify
  • docker拉取redis并使用
  • 代码训练LeetCode(44)螺旋矩阵
  • Notion 创始人 Ivan Zhao:传统软件开发是造桥,AI 开发更像酿酒,提供环境让 AI 自行发展
  • Highcharts 安装使用教程