- 改动:init/check 接受可选 targetDir(相对 CloudDir,SanitizeDir 校验),merge 物理落盘 CloudDir/<targetDir>/ - 秒传跨目录 copy-on-hit:库把旧物理文件复制进目标目录,OnInstantHit 携带新路径;源缺失回退正常上传 - 断点续传按目录隔离:同 MD5 在途任务目录不同则另起新任务 - TaskInfo 新增 TargetDir(宿主须持久化);Repository 12 方法接口零变更,向后兼容 v1.0.x - 新增 dir.go(SanitizeDir/路径拼装反解/copyFile)与 service_test.go(10 个用例:双模式秒传/续传隔离/merge 回归) - 原因:上传位置原本固定在 CloudDir 根,宿主无法实现网盘式选目录上传
121 lines
3.3 KiB
Go
121 lines
3.3 KiB
Go
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
|
||
// + 可选 targetDir:目标目录,相对 CloudDir,空=根目录/固定路径模式)
|
||
// POST /chunk 上传分片(multipart:uploadId/chunkIndex/chunk),幂等
|
||
// POST /merge 合并分片(JSON:uploadId),幂等
|
||
// GET /check?fileMD5=&targetDir= 秒传/续传检查(targetDir 可选)
|
||
// 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"), c.Query("targetDir"))
|
||
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
|
||
}
|