Softhub软件下载站实战开发(十一):软件分片上传接口实现

发布于:2025-07-05 ⋅ 阅读:(22) ⋅ 点赞:(0)

Softhub软件下载站实战开发(十一):软件分片上传接口实现 🚀

前言

上一篇文章中我们实现了图片和视频的上传下载,本文聚焦于软件的上传下载,和图片视频不同,软件小则几兆,多则几GB,单次上传占据巨大内存不提,还容易失败,为了解决这些问题,我们为Softhub软件下载站实现了​​分片上传​​功能。下面将详细介绍分片上传的实现原理和代码逻辑。

分片上传原理 🧩

分片上传是将大文件分割成多个小块(chunk)分别上传,最后在服务器端合并的技术。其优势在于:

  1. ​断点续传​​:某个分片上传失败可以单独重传
  2. ​并行上传​​:可以同时上传多个分片提高速度
  3. ​稳定性​​:小文件上传成功率更高
客户端 服务端 1. 初始化上传(文件信息) 返回uploadId 2. 上传分片n 返回结果 loop [分片上传] 3. 合并分片请求 合并文件 返回最终文件URL 客户端 服务端

如果软件存储到minio,合并后还需要再次上传到minio,minio提供的api已经是分片上传,无需手动实现。

接口设计 📡

我们设计了三个核心接口:

  1. ​初始化上传​​:/dsSoftwareResource/chunk/init
  2. ​上传分片​​:/dsSoftwareResource/chunk/upload
  3. ​合并分片​​:/dsSoftwareResource/chunk/merge

代码实现 💻

1. 初始化上传接口

// 初始化分片上传
func (s *sDsSoftwareResource) InitChunkUpload(ctx context.Context, req *api.ChunkInitReq) (res *api.ChunkInitRes, err error) {
	err = g.Try(ctx, func(ctx context.Context) {
		// 转换softwareId
		softwareId, err := convertToInt64(req.SoftwareId)
		liberr.ErrIsNil(ctx, err, "软件ID格式错误")

		// 检查软件是否存在
		software, err := dao.DsSoftware.Ctx(ctx).Where(dao.DsSoftware.Columns().Id, softwareId).One()
		liberr.ErrIsNil(ctx, err, "获取软件信息失败")
		if software == nil {
			liberr.ErrIsNil(ctx, fmt.Errorf("软件不存在"))
		}

		// 生成上传ID
		uploadId := gconv.String(gtime.TimestampNano())

		// 创建临时目录
		tempDir := gfile.Join(gfile.Temp(), "upload", fmt.Sprintf("%d_%s", softwareId, uploadId))
		if err := gfile.Mkdir(tempDir); err != nil {
			liberr.ErrIsNil(ctx, err, "创建临时目录失败")
		}

		res = &api.ChunkInitRes{
			UploadId: uploadId,
		}
	})
	return
}

2. 上传分片接口

func (s *sDsSoftwareResource) UploadChunk(ctx context.Context, req *api.ChunkUploadReq) (res *api.ChunkUploadRes, err error) {
	err = g.Try(ctx, func(ctx context.Context) {
		// 转换softwareId
		softwareId, err := convertToInt64(req.SoftwareId)
		liberr.ErrIsNil(ctx, err, "软件ID格式错误")

		// 验证上传会话
		tempDir := gfile.Join(gfile.Temp(), "upload", fmt.Sprintf("%d_%s", softwareId, req.UploadId))
		if !gfile.Exists(tempDir) {
			liberr.ErrIsNil(ctx, fmt.Errorf("无效的上传会话"))
		}

		// 保存分片
		chunkFile := gfile.Join(tempDir, fmt.Sprintf("chunk_%d", req.ChunkIndex))
		file, err := req.File.Open()
		if err != nil {
			liberr.ErrIsNil(ctx, err, "打开分片文件失败")
		}
		defer file.Close()

		// 读取文件内容
		fileBytes, err := io.ReadAll(file)
		if err != nil {
			liberr.ErrIsNil(ctx, err, "读取分片文件失败")
		}

		// 保存分片
		if err := gfile.PutBytes(chunkFile, fileBytes); err != nil {
			liberr.ErrIsNil(ctx, err, "保存分片失败")
		}

		res = &api.ChunkUploadRes{
			Success: true,
		}
	})
	return
}

3. 合并分片接口

开始合并
获取所有分片文件
创建合并文件
按顺序写入分片
计算文件MD5
上传到MinIO
保存资源信息
清理临时文件
返回结果
func (s *sDsSoftwareResource) MergeChunks(ctx context.Context, req *api.ChunkMergeReq) (res *api.ChunkMergeRes, err error) {
	err = g.Try(ctx, func(ctx context.Context) {
		// 转换softwareId
		softwareId, err := convertToInt64(req.SoftwareId)
		liberr.ErrIsNil(ctx, err, "软件ID格式错误")

		// 验证上传会话
		tempDir := gfile.Join(gfile.Temp(), "upload", fmt.Sprintf("%d_%s", softwareId, req.UploadId))
		g.Log().Debug(ctx, "临时目录路径:", tempDir)
		if !gfile.Exists(tempDir) {
			liberr.ErrIsNil(ctx, fmt.Errorf("无效的上传会话"))
		}

		// 获取分片文件列表
		chunkFiles, err := gfile.ScanDir(tempDir, "chunk_*")
		liberr.ErrIsNil(ctx, err, "获取分片文件列表失败")
		g.Log().Debug(ctx, "分片文件列表:", chunkFiles)

		// 合并分片
		mergedFile := gfile.Join(tempDir, "merged")
		g.Log().Debug(ctx, "合并文件路径:", mergedFile)
		outFile, err := gfile.OpenFile(mergedFile, os.O_CREATE|os.O_WRONLY, 0644)
		liberr.ErrIsNil(ctx, err, "创建合并文件失败")

		for _, chunkFile := range chunkFiles {
			chunkData := gfile.GetBytes(chunkFile)
			if _, err := outFile.Write(chunkData); err != nil {
				liberr.ErrIsNil(ctx, err, "写入合并文件失败")
			}
		}

		// 关闭写入文件句柄
		outFile.Close()

		// 计算MD5
		md5, err := gmd5.EncryptFile(mergedFile)
		liberr.ErrIsNil(ctx, err, "计算MD5失败")

		// 上传到MinIO
		drive := storage.MinioDrive{}
		// 构建存储路径:software/year/month/day/md5.softhub
		now := gtime.Now()
		objectName := fmt.Sprintf("software/%d/%02d/%02d/%s.softhub",
			now.Year(),
			now.Month(),
			now.Day(),
			md5)
		g.Log().Debug(ctx, "MinIO对象名称:", objectName)

		// 获取文件信息
		fileInfo, err := os.Stat(mergedFile)
		liberr.ErrIsNil(ctx, err, "获取文件信息失败")
		g.Log().Debug(ctx, "文件信息:", map[string]interface{}{
			"name": fileInfo.Name(),
			"size": fileInfo.Size(),
			"mode": fileInfo.Mode(),
		})

		// 打开文件用于上传
		file, err := os.Open(mergedFile)
		liberr.ErrIsNil(ctx, err, "打开文件失败")

		// 直接使用MinIO客户端上传
		client, err := drive.GetClient()
		liberr.ErrIsNil(ctx, err, "获取MinIO客户端失败")

		opts := minio.PutObjectOptions{
			ContentType: "application/octet-stream",
		}

		_, err = client.PutObject(ctx, config.MINIO_BUCKET, objectName, file, fileInfo.Size(), opts)
		liberr.ErrIsNil(ctx, err, "上传到MinIO失败")

		// 保存资源信息
		// 使用雪花算法生成资源ID
		resourceId := node.Generate().Int64()

		_, err = dao.DsSoftwareResource.Ctx(ctx).Insert(do.DsSoftwareResource{
			Id:            resourceId,       // 使用雪花算法生成的ID
			SoftwareId:    softwareId,       // 软件id
			ResourceName:  req.ResourceName, // 软件名称
			OriginName:    req.ResourceName, // 原始名称
			Version:       req.Version,      // 版本
			Md5:           md5,              // md5
			DownloadCount: 0,                // 下载次数
			Size:          fileInfo.Size(),  // 大小
			ResourceUrl:   objectName,       // 资源路径
			Default:       false,            // 是否默认
			Remark:        req.Remark,       // 备注
			CreatedBy:     SystemS.Context().GetUserId(ctx),
			UpdatedBy:     SystemS.Context().GetUserId(ctx),
		})
		liberr.ErrIsNil(ctx, err, "保存资源信息失败")

		res = &api.ChunkMergeRes{
			ResourceId: resourceId,
			FileUrl:    fmt.Sprintf("/minio/software/%s", objectName),
		}
		outFile.Close()
		file.Close()
		if err := os.RemoveAll(tempDir); err != nil {
			g.Log().Error(ctx, "强制删除临时目录失败:", tempDir, err)
		}
	})
	return
}

至此,软件管理的核心接口已经准备完成,后续可以编写前端页面进行对接。

3. 附录

3.1 id转换

在使用雪花算法(Snowflake)生成ID时,前端传递到后端时可能会出现精度损失,导致ID值发生变化。例如:

  • ​原始ID​​:717695602914721792
  • ​前端接收后​​:717695602914721800

因此前端需要传输字符串类型,后端进行转换

api定义

// 分片上传初始化请求
type ChunkInitReq struct {
	g.Meta     `path:"/dsSoftwareResource/chunk/init" method:"post" tags:"软件资源表" summary:"分片上传-初始化"`
	SoftwareId interface{} `json:"softwareId" v:"required#软件ID不能为空"`
	FileName   string      `json:"fileName" v:"required#文件名不能为空"`
	FileSize   int64       `json:"fileSize" v:"required#文件大小不能为空"`
	ChunkSize  int64       `json:"chunkSize" v:"required#分片大小不能为空"`
}

SoftwareId要定义成interface{},接口中进行转换

转换函数

func convertToInt64(id interface{}) (int64, error) {
	switch v := id.(type) {
	case int64:
		return v, nil
	case int:
		return int64(v), nil
	case uint:
		return int64(v), nil
	case uint64:
		return int64(v), nil
	case string:
		return strconv.ParseInt(v, 10, 64)
	case float64:
		return int64(v), nil
	default:
		return 0, fmt.Errorf("unsupported ID type: %T", id)
	}
}

softhub系列往期文章

  1. Softhub软件下载站实战开发(一):项目总览
  2. Softhub软件下载站实战开发(二):项目基础框架搭建
  3. Softhub软件下载站实战开发(三):平台管理模块实战
  4. Softhub软件下载站实战开发(四):代码生成器设计与实现
  5. Softhub软件下载站实战开发(五):分类模块实现
  6. Softhub软件下载站实战开发(六):软件配置面板实现
  7. Softhub软件下载站实战开发(七):集成MinIO实现文件存储功能
  8. Softhub软件下载站实战开发(八):编写软件后台管理
  9. Softhub软件下载站实战开发(九):编写软件配置管理界面
  10. Softhub软件下载站实战开发(十):实现图片视频上传下载接口


网站公告

今日签到

点亮在社区的每一天
去签到