文章目录
- [Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥](#Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥)
-
- 系统架构图
- [核心功能设计 🛠️](#核心功能设计 🛠️)
-
- [1. 文件上传流程](#1. 文件上传流程)
- [2. 关键技术实现](#2. 关键技术实现)
-
- [2.1 雪花算法](#2.1 雪花算法)
- [2.2 文件校验机制 ✅](#2.2 文件校验机制 ✅)
- [2.3 文件去重机制 🔍](#2.3 文件去重机制 🔍)
- [2.4 视频封面提取 🎞️](#2.4 视频封面提取 🎞️)
- [2.5 文件存储策略 📂](#2.5 文件存储策略 📂)
- [2.6 视频上传示例](#2.6 视频上传示例)
- [3. 文件查看实现 ⬇️](#3. 文件查看实现 ⬇️)
Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥
在上一篇文章中,我们实现了软件配置面板,实现了ai配置信息的存储,为后续富文本编辑器的ai功能提供了基础,本文致力于解决在富文本编辑器中图片和视频的上传查看功能。
系统架构图
上传文件 下载文件 读取 客户端 API接口 文件处理层 存储服务 MinIO存储 数据库 MySQL
核心功能设计 🛠️
1. 文件上传流程
客户端 服务端 MinIO 数据库 上传文件请求 验证文件类型和大小 计算文件MD5 检查文件是否已存在 返回已存在记录 直接返回文件URL 上传文件到MinIO 返回成功 保存文件元信息 返回成功 返回文件URL alt 文件已存在 文件不存在 客户端 服务端 MinIO 数据库
2. 关键技术实现
2.1 雪花算法
关键数据不能采取自增id方案,采用md5也会有碰撞和页分裂的问题,这里采用雪花算法来解决这一问题
安装
bash
go get -u "github.com/bwmarrin/snowflake"
初始化
go
var node *snowflake.Node
func init() {
var err error
node, err = snowflake.NewNode(1)
}
使用
go
id := node.Generate().Int64()
2.2 文件校验机制 ✅
go
// 检查文件类型
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值实现文件去重:
go
// 计算文件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提取视频首帧作为封面:
go
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
代码实现:
go
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 视频上传示例
go
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格式的元数据,前端根据返回的路径进行接口请求
以视频为例
go
// 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系列往期文章