文章目录
Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥
在上一篇文章中,我们实现了软件配置面板,实现了ai配置信息的存储,为后续富文本编辑器的ai功能提供了基础,本文致力于解决在富文本编辑器中图片和视频的上传查看功能。
系统架构图
核心功能设计 🛠️
1. 文件上传流程
2. 关键技术实现
2.1 雪花算法
关键数据不能采取自增id方案,采用md5也会有碰撞和页分裂的问题,这里采用雪花算法来解决这一问题
安装
go get -u "github.com/bwmarrin/snowflake"
初始化
var node *snowflake.Node
func init() {
var err error
node, 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 = true
break
}
}
if !isAllowed {
return fmt.Errorf("不支持的文件类型:%s", fileType)
}
// 检查文件大小
if req.File.Size > 10*1024*1024 { // 10MB
return 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 := 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格式的元数据,前端根据返回的路径进行接口请求
以视频为例
// 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系列往期文章