Files
4566704 455360bff0 feat: 支持 targetDir 指定上传目标目录(网盘式),固定路径模式不变
- 改动: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 根,宿主无法实现网盘式选目录上传
2026-09-26 08:47:55 +08:00

121 lines
3.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}