feat(fileupload): 并入通用大文件分片上传服务端核心

- 六接口(init/chunk/merge/check/task/abort)+ 分片幂等 + 断点续传 +
  .part 原子改名 + 合并整文件 MD5 校验 + 秒传
- 零宿主耦合:Repository 接口注入存储、Envelope 注入响应封装、
  OnMerged/OnInstantHit 回调衔接业务(仅依赖既有 gin/uuid)
- 自 FileUpload 项目 restful/upload 抽出;maintain-service 云端文件为其首个宿主
This commit is contained in:
2026-09-25 15:11:30 +08:00
parent 40c9f62c9f
commit 33574d4ac2
5 changed files with 711 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# zogo/fileupload
通用大文件分片上传 Go/Gin 服务端核心(zogo 子包)。
六接口(init/chunk/merge/check/task/abort)+ 分片幂等 + 断点续传 + `.part` 原子改名 + 合并整文件 MD5 校验 + 秒传。
不依赖宿主的响应封装、ORM 或业务表——存储走 `Repository` 接口注入,业务衔接走 `Options` 回调。
## 用法
```go
import "git.zeroonesoft.cn/golib/zogo/fileupload"
opts := fileupload.Options{
ChunkRoot: "./data/upload-chunks", // 分片临时目录(建议在静态根之外)
CloudDir: "./data/uploads/cloud", // 合并产物目录
WebPathPrefix: "cloud", // storagePath 的 Web 前缀(须与 CloudDir 相对静态根的子目录一致)
URLPrefix: "/uploads", // fileUrl = URLPrefix + "/" + storagePath
MaxTotalSizeMB: 2048, // 0=不限
OnMerged: func(t fileupload.TaskInfo) error {
// 合并成功:宿主写自己的业务表(如云盘记录行);失败则 merge 整体失败
return nil
},
OnInstantHit: func(t fileupload.TaskInfo) error {
// 秒传命中:宿主确保业务侧可见行(可空回调)
return nil
},
Envelope: func(c *gin.Context, resp any, err error) {
// 宿主自己的响应封装,如 httpx.HandleResult(c, resp, err)
},
}
_ = fileupload.Mount(api.Group("/upload"), opts, myRepo,
fileupload.WithIdentity(func(c *gin.Context) (string, int64) {
return c.GetString("UserName"), c.GetInt64("TenantId")
}))
```
`Repository`(12 个方法)由宿主按自己的表结构实现;软删/硬删自定,但删除后查询必须不可见。
鉴权由宿主路由组的 JWT 中间件统一负责,本库不感知。
## 版本
- v0.1.0 首版:自 FileUpload 项目 restful/upload 模块抽出
+137
View File
@@ -0,0 +1,137 @@
// Package fileupload 通用大文件分片上传(Go/Gin 服务端核心)。
//
// 职责:init/chunk/merge/check/task/abort 六接口的路由挂载与业务逻辑——
// 分片幂等、断点续传、.part 原子改名、合并时整文件 MD5 校验、秒传。
// 宿主差异全部通过 Options 与 Repository 注入,本包不依赖任何宿主的
// 响应封装、ORM 生成代码或业务表:
//
// fileupload.Mount(api.Group("/upload"), opts, repo)
//
// 与业务层的衔接:
// - merge 成功 → OnMerged(TaskInfo)(宿主写自己的业务表/云盘记录);
// 回调失败则 merge 整体失败、任务置 error
// - 秒传命中 → OnInstantHit(TaskInfo)(宿主确保业务侧可见行,可空回调)
// - 响应封装 → Options.Envelope(必填,如 httpx.HandleResult / gin.H{code:200,...})
package fileupload
import (
"errors"
"math"
"path"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// TaskInfo 上传任务的库级视图(与宿主的表结构解耦)。
type TaskInfo struct {
Id int64
UploadId string
FileName string
FileSize int64
FileMd5 string
MimeType string
ChunkSize int64
TotalChunk int32
Status string // uploading / merging / done / error
StoragePath string // 相对路径(如 cloud/xxx),由宿主的目录约定决定
Operator string
TenantId int64
}
// ChunkInfo 分片记录的库级视图。
type ChunkInfo struct {
UploadId string
ChunkIndex int32
ChunkSize int64
ChunkMd5 string
}
// Status 常量。
const (
StatusUploading = "uploading"
StatusMerging = "merging"
StatusDone = "done"
StatusError = "error"
)
// DefaultMaxChunkBytes 单分片硬上限(防滥用,与常见 multipart 内存缓冲同量级)。
const DefaultMaxChunkBytes int64 = 64 << 20
// Envelope 宿主响应封装:resp 非 nil 时写成功响应,err 非 nil 时写失败响应。
type Envelope func(c *gin.Context, resp any, err error)
// Options 宿主注入配置。
type Options struct {
// ChunkRoot 分片临时根目录;库在其下建 <uploadId>/ 子目录。
// 建议置于宿主静态根之外,避免分片被静态服务暴露。
ChunkRoot string
// CloudDir 合并产物目录(宿主的云盘/文件目录约定)。
CloudDir string
// WebPathPrefix storagePath 的 Web 相对前缀(如 "cloud"),空则无前缀。
// 最终 storagePath = WebPathPrefix + "/" + 产物名(存库、回调、BS 拼接均用该值),
// 需与 CloudDir 相对宿主静态根的子目录一致。
WebPathPrefix string
// URLPrefix 拼接 fileUrl 的静态前缀,默认 "/uploads";
// 最终 fileUrl = URLPrefix + "/" + storagePath。
URLPrefix string
// MaxTotalSizeMB 文件总大小上限(MB),0=不限制。
MaxTotalSizeMB int64
// MaxChunkBytes 单分片字节上限,0=DefaultMaxChunkBytes(64MB)。
MaxChunkBytes int64
// OnMerged 合并成功回调(宿主写业务表等)。返回 err 则 merge 失败、任务置 error。
OnMerged func(t TaskInfo) error
// OnInstantHit 秒传命中回调(宿主确保业务侧可见行)。可空。
OnInstantHit func(t TaskInfo) error
// Envelope 响应封装(必填)。
Envelope Envelope
}
func (o *Options) validate() error {
if o.ChunkRoot == "" || o.CloudDir == "" {
return errors.New("fileupload: Options.ChunkRoot / CloudDir 必填")
}
if o.Envelope == nil {
return errors.New("fileupload: Options.Envelope 必填")
}
if o.MaxChunkBytes <= 0 {
o.MaxChunkBytes = DefaultMaxChunkBytes
}
if o.URLPrefix == "" {
o.URLPrefix = "/uploads"
}
return nil
}
func (o *Options) maxChunkBytes() int64 {
if o.MaxChunkBytes > 0 {
return o.MaxChunkBytes
}
return DefaultMaxChunkBytes
}
// expectChunks 期望分片数:ceil(fileSize/chunkSize),空文件视为 1 个空分片。
func expectChunks(fileSize, chunkSize int64) int {
if chunkSize <= 0 {
return 0
}
n := int(math.Ceil(float64(fileSize) / float64(chunkSize)))
if n == 0 {
n = 1
}
return n
}
// sanitizeFileName 取路径基名并剥离路径穿越/非法字符。
func sanitizeFileName(name string) string {
name = path.Base(strings.TrimSpace(name))
name = strings.ReplaceAll(name, "\\", "")
if name == "." || name == ".." || name == "/" || name == "" {
return ""
}
return name
}
// newUploadID 生成上传任务 ID(uuid v4,URL 安全)。
func newUploadID() string { return uuid.NewString() }
+119
View File
@@ -0,0 +1,119 @@
package fileupload
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
)
// Mount 挂载分片上传路由到 r(宿主自行决定路由组,如 api.Group("/upload")):
//
// POST /init 初始化(JSON:fileName/fileSize/fileMD5/mimeType/chunkSize/totalChunk)
// POST /chunk 上传分片(multipart:uploadId/chunkIndex/chunk),幂等
// POST /merge 合并分片(JSON:uploadId),幂等
// GET /check?fileMD5= 秒传/续传检查
// GET /task/:uploadId 任务详情与已确认分片
// DELETE /task/:uploadId 取消任务并清理
//
// 身份(operator/tenant)由宿主在 handler 外层取好后经 identity 注入;
// 响应统一走 Options.Envelope。
type Identity func(c *gin.Context) (operator string, tenantID int64)
type mountConfig struct {
identity Identity
}
// MountOption 挂载选项。
type MountOption func(*mountConfig)
// WithIdentity 注入身份提取(从 JWT 中间件写入的 context 取 UserId/UserName 等)。
func WithIdentity(fn Identity) MountOption {
return func(m *mountConfig) { m.identity = fn }
}
// Mount 挂载路由。r 建议为 api.Group("/upload")。
func Mount(r *gin.RouterGroup, opts Options, repo Repository, mos ...MountOption) error {
if err := opts.validate(); err != nil {
return err
}
if repo == nil {
return errors.New("fileupload: Repository 必填")
}
mc := &mountConfig{}
for _, mo := range mos {
mo(mc)
}
svc := newService(opts, repo)
r.POST("/init", func(c *gin.Context) {
var req InitReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "参数错误: " + err.Error()})
return
}
operator, tenantID := mc.identity(c)
resp, err := svc.init(&req, operator, tenantID)
opts.Envelope(c, resp, err)
})
r.POST("/chunk", func(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
opts.Envelope(c, nil, errors.New("请使用 multipart/form-data 上传"))
return
}
files := form.File["chunk"]
if len(files) != 1 {
opts.Envelope(c, nil, errors.New("缺少分片文件字段 chunk"))
return
}
chunkIndex := atoiDefault(c.PostForm("chunkIndex"), -1)
resp, err := svc.chunk(c.PostForm("uploadId"), chunkIndex, files[0])
if err != nil {
opts.Envelope(c, nil, err)
return
}
opts.Envelope(c, resp, nil)
})
r.POST("/merge", func(c *gin.Context) {
var req MergeReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "参数错误: " + err.Error()})
return
}
resp, err := svc.merge(req.UploadId)
opts.Envelope(c, resp, err)
})
r.GET("/check", func(c *gin.Context) {
resp, err := svc.check(c.Query("fileMD5"))
opts.Envelope(c, resp, err)
})
r.GET("/task/:uploadId", func(c *gin.Context) {
resp, err := svc.taskInfo(c.Param("uploadId"))
opts.Envelope(c, resp, err)
})
r.DELETE("/task/:uploadId", func(c *gin.Context) {
resp, err := svc.abort(c.Param("uploadId"))
opts.Envelope(c, resp, err)
})
return nil
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return def
}
n = n*10 + int(r-'0')
}
return n
}
+32
View File
@@ -0,0 +1,32 @@
package fileupload
// Repository 存储层接口:宿主用自己的 ORM/表结构实现(本包不关心表怎么建)。
// 语义约定:
// - Delete* 为软删或硬删由宿主决定,但之后 GetTaskByUploadID / 各 Find 必须查不到;
// - Find* 只需返回未删除的最新一条。
type Repository interface {
// CreateTask 新建任务(Id/UploadId 已由本包赋值)。
CreateTask(t *TaskInfo) error
// GetTaskByUploadID 按 uploadId 取未删除任务;不存在返回 (nil, nil)。
GetTaskByUploadID(uploadID string) (*TaskInfo, error)
// FindDoneByMD5 取同 MD5 已完成任务中最新一条;无则 (nil, nil)。
FindDoneByMD5(md5 string) (*TaskInfo, error)
// FindUploadingByMD5 取同 MD5 进行中任务一条(断点续传);无则 (nil, nil)。
FindUploadingByMD5(md5 string) (*TaskInfo, error)
// UpdateTaskMerged 合并成功:置 done 并记录 storagePath。
UpdateTaskMerged(id int64, storagePath string) error
// SetTaskStatus 更新任务状态(error/merging 等流转)。
SetTaskStatus(id int64, status string) error
// CountChunks 统计任务已确认分片数。
CountChunks(uploadID string) (int, error)
// ChunkExists 分片是否已确认(幂等依据)。
ChunkExists(uploadID string, index int32) (bool, error)
// CreateChunk 写入分片确认记录。
CreateChunk(c *ChunkInfo) error
// ListChunkIndexes 已确认分片序号升序。
ListChunkIndexes(uploadID string) ([]int32, error)
// DeleteChunks 删除任务的分片记录。
DeleteChunks(uploadID string) error
// DeleteTaskByUploadID 删除任务。
DeleteTaskByUploadID(uploadID string) error
}
+382
View File
@@ -0,0 +1,382 @@
package fileupload
import (
"crypto/md5"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strconv"
"time"
)
// service 核心业务,持 Options + Repository。
type service struct {
opts Options
repo Repository
}
func newService(opts Options, repo Repository) *service {
return &service{opts: opts, repo: repo}
}
func (s *service) chunkDir(uploadID string) string {
return filepath.Join(s.opts.ChunkRoot, uploadID)
}
func (s *service) fileURL(storagePath string) string {
return s.opts.URLPrefix + "/" + storagePath
}
// ── init ────────────────────────────────────────────────────────────
type InitReq struct {
FileName string `json:"fileName" binding:"required,max=255"`
FileSize int64 `json:"fileSize" binding:"min=0"`
FileMD5 string `json:"fileMD5" binding:"required,len=32"`
MimeType string `json:"mimeType" binding:"max=128"`
ChunkSize int64 `json:"chunkSize" binding:"required,min=1"`
TotalChunk int `json:"totalChunk" binding:"required,min=1"`
}
func (s *service) init(req *InitReq, operator string, tenantID int64) (any, error) {
if req.ChunkSize <= 0 || req.ChunkSize > s.opts.maxChunkBytes() {
return nil, fmt.Errorf("chunkSize 超出范围 (0, %d]", s.opts.maxChunkBytes())
}
if req.FileSize < 0 {
return nil, errors.New("fileSize 非法")
}
if len(req.FileMD5) != 32 {
return nil, errors.New("fileMD5 必须为 32 位十六进制")
}
if expect := expectChunks(req.FileSize, req.ChunkSize); req.TotalChunk != expect {
return nil, fmt.Errorf("totalChunk(%d) 与 fileSize(%d)/chunkSize(%d) 不匹配,应为 %d",
req.TotalChunk, req.FileSize, req.ChunkSize, expect)
}
if s.opts.MaxTotalSizeMB > 0 && req.FileSize > s.opts.MaxTotalSizeMB*1024*1024 {
return nil, fmt.Errorf("文件超过总大小上限 %dMB", s.opts.MaxTotalSizeMB)
}
// 1. 秒传:同 MD5 已完成任务,复用其文件
done, err := s.repo.FindDoneByMD5(req.FileMD5)
if err != nil {
return nil, err
}
if done != nil && done.StoragePath != "" {
if s.opts.OnInstantHit != nil {
if err := s.opts.OnInstantHit(*done); err != nil {
return nil, err
}
}
return map[string]any{
"uploadId": "",
"skipUpload": true,
"fileUrl": s.fileURL(done.StoragePath),
}, nil
}
// 2. 断点续传:同 MD5 进行中任务
resuming, err := s.repo.FindUploadingByMD5(req.FileMD5)
if err != nil {
return nil, err
}
if resuming != nil {
indexes, err := s.repo.ListChunkIndexes(resuming.UploadId)
if err != nil {
return nil, err
}
return map[string]any{
"uploadId": resuming.UploadId,
"skipUpload": false,
"uploadedChunks": indexes,
}, nil
}
// 3. 新建任务
name := sanitizeFileName(req.FileName)
if name == "" {
return nil, errors.New("文件名非法")
}
task := TaskInfo{
UploadId: newUploadID(),
FileName: name,
FileSize: req.FileSize,
FileMd5: req.FileMD5,
MimeType: req.MimeType,
ChunkSize: req.ChunkSize,
TotalChunk: int32(req.TotalChunk),
Status: StatusUploading,
Operator: operator,
TenantId: tenantID,
}
if err := s.repo.CreateTask(&task); err != nil {
return nil, err
}
return map[string]any{
"uploadId": task.UploadId,
"skipUpload": false,
"uploadedChunks": []int32{},
}, nil
}
// ── chunk ───────────────────────────────────────────────────────────
func (s *service) chunk(uploadID string, chunkIndex int, fh *multipart.FileHeader) (any, error) {
if uploadID == "" {
return nil, errors.New("缺少 uploadId")
}
task, err := s.repo.GetTaskByUploadID(uploadID)
if err != nil {
return nil, err
}
if task == nil {
return nil, errors.New("上传任务不存在")
}
if task.Status != StatusUploading {
return nil, fmt.Errorf("任务状态异常: %s", task.Status)
}
if chunkIndex < 0 || int32(chunkIndex) >= task.TotalChunk {
return nil, fmt.Errorf("chunkIndex %d 超出范围 [0, %d)", chunkIndex, task.TotalChunk)
}
if fh.Size > s.opts.maxChunkBytes() {
return nil, fmt.Errorf("分片超过单片上限 %d MB", s.opts.maxChunkBytes()/1024/1024)
}
// 幂等:已确认的分片直接成功
exists, err := s.repo.ChunkExists(uploadID, int32(chunkIndex))
if err != nil {
return nil, err
}
if exists {
return map[string]any{"chunkIndex": chunkIndex}, nil
}
dir := s.chunkDir(uploadID)
if err := os.MkdirAll(dir, 0o766); err != nil {
return nil, err
}
chunkPath := filepath.Join(dir, strconv.Itoa(chunkIndex))
dst, err := os.Create(chunkPath)
if err != nil {
return nil, err
}
src, err := fh.Open()
if err != nil {
_ = dst.Close()
return nil, err
}
hash := md5.New()
written, copyErr := io.Copy(io.MultiWriter(dst, hash), src)
closeErr := dst.Close()
_ = src.Close()
if copyErr != nil {
return nil, copyErr
}
if closeErr != nil {
return nil, closeErr
}
if err := s.repo.CreateChunk(&ChunkInfo{
UploadId: uploadID,
ChunkIndex: int32(chunkIndex),
ChunkSize: written,
ChunkMd5: fmt.Sprintf("%x", hash.Sum(nil)),
}); err != nil {
// 并发下另一请求刚写入同分片:唯一冲突视为幂等成功
again, e2 := s.repo.ChunkExists(uploadID, int32(chunkIndex))
if e2 == nil && again {
return map[string]any{"chunkIndex": chunkIndex}, nil
}
return nil, err
}
return map[string]any{"chunkIndex": chunkIndex}, nil
}
// ── merge ───────────────────────────────────────────────────────────
type MergeReq struct {
UploadId string `json:"uploadId" binding:"required"`
}
func (s *service) merge(uploadID string) (any, error) {
task, err := s.repo.GetTaskByUploadID(uploadID)
if err != nil {
return nil, err
}
if task == nil {
return nil, errors.New("上传任务不存在")
}
// 幂等:已完成直接返回原地址;合并中拒绝重复触发
if task.Status == StatusDone && task.StoragePath != "" {
return map[string]any{"fileUrl": s.fileURL(task.StoragePath)}, nil
}
if task.Status == StatusMerging {
return nil, errors.New("任务正在合并中,请勿重复提交")
}
count, err := s.repo.CountChunks(uploadID)
if err != nil {
return nil, err
}
if count < int(task.TotalChunk) {
return nil, fmt.Errorf("分片不完整,已上传 %d/%d", count, task.TotalChunk)
}
_ = s.repo.SetTaskStatus(task.Id, StatusMerging)
// 先写 .part,全部成功后改名,避免半成品文件残留
partPath := filepath.Join(s.chunkDir(uploadID), "merged.part")
part, err := os.Create(partPath)
if err != nil {
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, err
}
hash := md5.New()
for i := int32(0); i < task.TotalChunk; i++ {
src, err := os.Open(filepath.Join(s.chunkDir(uploadID), strconv.Itoa(int(i))))
if err != nil {
_ = part.Close()
_ = os.Remove(partPath)
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, fmt.Errorf("读取分片 %d 失败: %w", i, err)
}
_, copyErr := io.Copy(part, io.TeeReader(src, hash))
_ = src.Close()
if copyErr != nil {
_ = part.Close()
_ = os.Remove(partPath)
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, fmt.Errorf("合并分片 %d 失败: %w", i, copyErr)
}
}
if err := part.Close(); err != nil {
_ = os.Remove(partPath)
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, err
}
// 整文件 MD5 校验:保证秒传可信
if actual := fmt.Sprintf("%x", hash.Sum(nil)); actual != task.FileMd5 {
_ = os.Remove(partPath)
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, fmt.Errorf("合并结果校验失败:实际 MD5 %s 与声称的 %s 不一致", actual, task.FileMd5)
}
// 改名进宿主产物目录
if err := os.MkdirAll(s.opts.CloudDir, 0o766); err != nil {
_ = os.Remove(partPath)
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, err
}
storageName := strconv.FormatInt(time.Now().UnixNano(), 10) + "_" + task.FileName
// storagePath 是 Web 相对路径(含 WebPathPrefix 前缀,如 cloud/xxx),
// 物理落盘固定在 CloudDir 根下(前缀只是 URL/拼接约定,不是子目录)
finalPath := filepath.Join(s.opts.CloudDir, storageName)
storagePath := storageName
if s.opts.WebPathPrefix != "" {
storagePath = s.opts.WebPathPrefix + "/" + storageName
}
if err := os.Rename(partPath, finalPath); err != nil {
_ = os.Remove(partPath)
_ = s.repo.SetTaskStatus(task.Id, StatusError)
return nil, err
}
task.Status = StatusDone
task.StoragePath = storagePath
if err := s.repo.UpdateTaskMerged(task.Id, storagePath); err != nil {
return nil, err
}
// 宿主业务衔接(如写云盘记录行);失败则 merge 整体失败
if s.opts.OnMerged != nil {
if err := s.opts.OnMerged(*task); err != nil {
return nil, err
}
}
// 分片目录异步清理
go func(dir string) {
_ = os.RemoveAll(dir)
}(s.chunkDir(uploadID))
return map[string]any{"fileUrl": s.fileURL(storagePath)}, nil
}
// ── check / task / abort ────────────────────────────────────────────
func (s *service) check(fileMD5 string) (any, error) {
if len(fileMD5) != 32 {
return map[string]any{"status": "none"}, nil
}
done, err := s.repo.FindDoneByMD5(fileMD5)
if err != nil {
return nil, err
}
if done != nil && done.StoragePath != "" {
return map[string]any{
"status": StatusDone,
"fileUrl": s.fileURL(done.StoragePath),
}, nil
}
resuming, err := s.repo.FindUploadingByMD5(fileMD5)
if err != nil {
return nil, err
}
if resuming != nil {
indexes, err := s.repo.ListChunkIndexes(resuming.UploadId)
if err != nil {
return nil, err
}
return map[string]any{
"status": StatusUploading,
"uploadId": resuming.UploadId,
"uploadedChunks": indexes,
}, nil
}
return map[string]any{"status": "none"}, nil
}
func (s *service) taskInfo(uploadID string) (any, error) {
task, err := s.repo.GetTaskByUploadID(uploadID)
if err != nil {
return nil, err
}
if task == nil {
return nil, errors.New("任务不存在")
}
indexes, err := s.repo.ListChunkIndexes(uploadID)
if err != nil {
return nil, err
}
return map[string]any{"task": task, "uploadedChunks": indexes}, nil
}
func (s *service) abort(uploadID string) (any, error) {
task, err := s.repo.GetTaskByUploadID(uploadID)
if err != nil {
return nil, err
}
if task == nil {
return nil, errors.New("上传任务不存在")
}
if task.Status == StatusDone {
return nil, errors.New("任务已完成,无需取消")
}
if task.Status == StatusMerging {
return nil, errors.New("任务正在合并中,无法取消")
}
if err := s.repo.DeleteChunks(uploadID); err != nil {
return nil, err
}
if err := s.repo.DeleteTaskByUploadID(uploadID); err != nil {
return nil, err
}
go func(dir string) {
_ = os.RemoveAll(dir)
}(s.chunkDir(uploadID))
return nil, nil
}