文章目錄
- 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軟件下載站實戰開發(九):編寫軟件配置管理界面