Softhub软件下载站实战开发(十):实现图片视频上传下载接口
文章目录
- Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥
- 系统架构图
- 核心功能设计 🛠️
- 1. 文件上传流程
- 2. 关键技术实现
- 2.1 雪花算法
- 2.2 文件校验机制 ✅
- 2.3 文件去重机制 🔍
- 2.4 视频封面提取 🎞️
- 2.5 文件存储策略 📂
- 2.6 视频上传示例
- 3. 文件查看实现 ⬇️
Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥
在上一篇文章中,我们实现了软件配置面板,实现了ai配置信息的存储,为后续富文本编辑器的ai功能提供了基础,本文致力于解决在富文本编辑器中图片和视频的上传查看功能。
系统架构图
核心功能设计 🛠️
1. 文件上传流程
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系列往期文章
- Softhub软件下载站实战开发(一):项目总览
- Softhub软件下载站实战开发(二):项目基础框架搭建
- Softhub软件下载站实战开发(三):平台管理模块实战
- Softhub软件下载站实战开发(四):代码生成器设计与实现
- Softhub软件下载站实战开发(五):分类模块实现
- Softhub软件下载站实战开发(六):软件配置面板实现
- Softhub软件下载站实战开发(七):集成MinIO实现文件存储功能
- Softhub软件下载站实战开发(八):编写软件后台管理
- Softhub软件下载站实战开发(九):编写软件配置管理界面