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 根,宿主无法实现网盘式选目录上传
This commit is contained in:
+74
-22
@@ -7,8 +7,10 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -39,6 +41,8 @@ type InitReq struct {
|
||||
MimeType string `json:"mimeType" binding:"max=128"`
|
||||
ChunkSize int64 `json:"chunkSize" binding:"required,min=1"`
|
||||
TotalChunk int `json:"totalChunk" binding:"required,min=1"`
|
||||
// TargetDir 目标目录(相对 CloudDir,/ 分隔),空=根目录(固定路径模式)。
|
||||
TargetDir string `json:"targetDir" binding:"max=500"`
|
||||
}
|
||||
|
||||
func (s *service) init(req *InitReq, operator string, tenantID int64) (any, error) {
|
||||
@@ -58,31 +62,38 @@ func (s *service) init(req *InitReq, operator string, tenantID int64) (any, erro
|
||||
if s.opts.MaxTotalSizeMB > 0 && req.FileSize > s.opts.MaxTotalSizeMB*1024*1024 {
|
||||
return nil, fmt.Errorf("文件超过总大小上限 %dMB", s.opts.MaxTotalSizeMB)
|
||||
}
|
||||
targetDir, err := SanitizeDir(req.TargetDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. 秒传:同 MD5 已完成任务,复用其文件
|
||||
// 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
|
||||
if hit, ok := s.instantHit(done, targetDir); ok {
|
||||
if s.opts.OnInstantHit != nil {
|
||||
if err := s.opts.OnInstantHit(hit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"uploadId": "",
|
||||
"skipUpload": true,
|
||||
"fileUrl": s.fileURL(hit.StoragePath),
|
||||
}, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"uploadId": "",
|
||||
"skipUpload": true,
|
||||
"fileUrl": s.fileURL(done.StoragePath),
|
||||
}, nil
|
||||
// 秒传不可用(旧物理文件缺失/复制失败)→ 落入正常上传流程
|
||||
}
|
||||
|
||||
// 2. 断点续传:同 MD5 进行中任务
|
||||
// 2. 断点续传:同 MD5 进行中任务(目标目录不同则不复用,另起新任务)
|
||||
resuming, err := s.repo.FindUploadingByMD5(req.FileMD5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resuming != nil {
|
||||
if resuming != nil && resuming.TargetDir == targetDir {
|
||||
indexes, err := s.repo.ListChunkIndexes(resuming.UploadId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -108,6 +119,7 @@ func (s *service) init(req *InitReq, operator string, tenantID int64) (any, erro
|
||||
ChunkSize: req.ChunkSize,
|
||||
TotalChunk: int32(req.TotalChunk),
|
||||
Status: StatusUploading,
|
||||
TargetDir: targetDir,
|
||||
Operator: operator,
|
||||
TenantId: tenantID,
|
||||
}
|
||||
@@ -121,6 +133,33 @@ func (s *service) init(req *InitReq, operator string, tenantID int64) (any, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// instantHit 处理秒传命中:
|
||||
// - 目标目录为空(固定模式)或与旧任务一致 → 直接复用旧 storagePath,行为与 v1.0.x 一致;
|
||||
// - 跨目录 → 把旧物理文件复制进目标目录,返回携带新 storagePath/TargetDir 的命中视图;
|
||||
//
|
||||
// ok=false 表示无法秒传(旧物理文件缺失/复制失败),调用方回退正常分片上传。
|
||||
func (s *service) instantHit(done *TaskInfo, targetDir string) (TaskInfo, bool) {
|
||||
hit := *done
|
||||
if targetDir == "" || targetDir == done.TargetDir {
|
||||
return hit, true
|
||||
}
|
||||
src := s.physicalPath(done.StoragePath)
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return TaskInfo{}, false
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(done.StoragePath, "\\", "/"))
|
||||
dstDir := filepath.Join(s.opts.CloudDir, filepath.FromSlash(targetDir))
|
||||
if err := os.MkdirAll(dstDir, 0o766); err != nil {
|
||||
return TaskInfo{}, false
|
||||
}
|
||||
if err := copyFile(src, filepath.Join(dstDir, name)); err != nil {
|
||||
return TaskInfo{}, false
|
||||
}
|
||||
hit.StoragePath = buildStoragePath(s.opts.WebPathPrefix, targetDir, name)
|
||||
hit.TargetDir = targetDir
|
||||
return hit, true
|
||||
}
|
||||
|
||||
// ── chunk ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *service) chunk(uploadID string, chunkIndex int, fh *multipart.FileHeader) (any, error) {
|
||||
@@ -208,6 +247,11 @@ func (s *service) merge(uploadID string) (any, error) {
|
||||
if task == nil {
|
||||
return nil, errors.New("上传任务不存在")
|
||||
}
|
||||
// 目标目录在 init 时已 sanitize,此处防御性重校验(防宿主存脏数据)
|
||||
targetDir, err := SanitizeDir(task.TargetDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 幂等:已完成直接返回原地址;合并中拒绝重复触发
|
||||
if task.Status == StatusDone && task.StoragePath != "" {
|
||||
@@ -265,20 +309,21 @@ func (s *service) merge(uploadID string) (any, error) {
|
||||
return nil, fmt.Errorf("合并结果校验失败:实际 MD5 %s 与声称的 %s 不一致", actual, task.FileMd5)
|
||||
}
|
||||
|
||||
// 改名进宿主产物目录
|
||||
if err := os.MkdirAll(s.opts.CloudDir, 0o766); err != nil {
|
||||
// 改名进宿主产物目录(targetDir 是 CloudDir 下的真实子目录,空=根目录)
|
||||
finalDir := s.opts.CloudDir
|
||||
if targetDir != "" {
|
||||
finalDir = filepath.Join(s.opts.CloudDir, filepath.FromSlash(targetDir))
|
||||
}
|
||||
if err := os.MkdirAll(finalDir, 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
|
||||
}
|
||||
// storagePath 是 Web 相对路径(WebPathPrefix + targetDir + 产物名),
|
||||
// 前缀只是 URL/拼接约定,targetDir 才是物理子目录
|
||||
finalPath := filepath.Join(finalDir, storageName)
|
||||
storagePath := buildStoragePath(s.opts.WebPathPrefix, targetDir, storageName)
|
||||
if err := os.Rename(partPath, finalPath); err != nil {
|
||||
_ = os.Remove(partPath)
|
||||
_ = s.repo.SetTaskStatus(task.Id, StatusError)
|
||||
@@ -308,10 +353,17 @@ func (s *service) merge(uploadID string) (any, error) {
|
||||
|
||||
// ── check / task / abort ────────────────────────────────────────────
|
||||
|
||||
func (s *service) check(fileMD5 string) (any, error) {
|
||||
// check 秒传/续传预检,targetDir 与 init 同源:
|
||||
// - done 命中照常返回(init 会按目录做 copy-on-hit);
|
||||
// - uploading 命中仅在目标目录一致时返回(目录不同时 init 会另起新任务,此处报 none)。
|
||||
func (s *service) check(fileMD5, targetDir string) (any, error) {
|
||||
if len(fileMD5) != 32 {
|
||||
return map[string]any{"status": "none"}, nil
|
||||
}
|
||||
dir, err := SanitizeDir(targetDir)
|
||||
if err != nil {
|
||||
return map[string]any{"status": "none"}, nil
|
||||
}
|
||||
done, err := s.repo.FindDoneByMD5(fileMD5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -326,7 +378,7 @@ func (s *service) check(fileMD5 string) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resuming != nil {
|
||||
if resuming != nil && resuming.TargetDir == dir {
|
||||
indexes, err := s.repo.ListChunkIndexes(resuming.UploadId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user