feat: 独立通用大文件分片上传服务端核心(自 zogo/fileupload v0.3.2 抽出)

- 六接口路由:init / chunk / merge / check / task / abort
- Repository 接口注入存储,宿主实现 12 方法即可接入
- OnMerged / OnInstantHit 回调衔接宿主业务(如写云端文件表)
- Envelope 注入响应封装,与宿主 httpx 解耦
- 与 zogo v0.3.2 的 fileupload 子包同源,后续在此仓库独立演进
This commit is contained in:
2026-09-25 15:21:21 +08:00
commit 011eb4348b
8 changed files with 846 additions and 0 deletions
+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
}