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:
2026-09-26 08:47:55 +08:00
parent 011eb4348b
commit 455360bff0
6 changed files with 625 additions and 31 deletions
+418
View File
@@ -0,0 +1,418 @@
package fileupload
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// ── 内存版 Repository(测试桩) ──────────────────────────────────────
type memRepo struct {
nextID int64
tasks map[int64]*TaskInfo
byUID map[string]int64
chunks map[string]map[int32]struct{}
}
func newMemRepo() *memRepo {
return &memRepo{
tasks: map[int64]*TaskInfo{},
byUID: map[string]int64{},
chunks: map[string]map[int32]struct{}{},
}
}
func (r *memRepo) CreateTask(t *TaskInfo) error {
r.nextID++
cp := *t
cp.Id = r.nextID
r.tasks[cp.Id] = &cp
r.byUID[cp.UploadId] = cp.Id
return nil
}
func (r *memRepo) GetTaskByUploadID(uploadID string) (*TaskInfo, error) {
id, ok := r.byUID[uploadID]
if !ok {
return nil, nil
}
cp := *r.tasks[id]
return &cp, nil
}
func (r *memRepo) FindDoneByMD5(md5 string) (*TaskInfo, error) {
var best *TaskInfo
for _, t := range r.tasks {
if t.FileMd5 == md5 && t.Status == StatusDone {
if best == nil || t.Id > best.Id {
best = t
}
}
}
if best == nil {
return nil, nil
}
cp := *best
return &cp, nil
}
func (r *memRepo) FindUploadingByMD5(md5 string) (*TaskInfo, error) {
var best *TaskInfo
for _, t := range r.tasks {
if t.FileMd5 == md5 && t.Status == StatusUploading {
if best == nil || t.Id > best.Id {
best = t
}
}
}
if best == nil {
return nil, nil
}
cp := *best
return &cp, nil
}
func (r *memRepo) UpdateTaskMerged(id int64, storagePath string) error {
r.tasks[id].Status = StatusDone
r.tasks[id].StoragePath = storagePath
return nil
}
func (r *memRepo) SetTaskStatus(id int64, status string) error {
r.tasks[id].Status = status
return nil
}
func (r *memRepo) CountChunks(uploadID string) (int, error) {
return len(r.chunks[uploadID]), nil
}
func (r *memRepo) ChunkExists(uploadID string, index int32) (bool, error) {
_, ok := r.chunks[uploadID][index]
return ok, nil
}
func (r *memRepo) CreateChunk(c *ChunkInfo) error {
if r.chunks[c.UploadId] == nil {
r.chunks[c.UploadId] = map[int32]struct{}{}
}
r.chunks[c.UploadId][c.ChunkIndex] = struct{}{}
return nil
}
func (r *memRepo) ListChunkIndexes(uploadID string) ([]int32, error) {
var out []int32
for i := range r.chunks[uploadID] {
out = append(out, i)
}
sort.Slice(out, func(a, b int) bool { return out[a] < out[b] })
return out, nil
}
func (r *memRepo) DeleteChunks(uploadID string) error {
delete(r.chunks, uploadID)
return nil
}
func (r *memRepo) DeleteTaskByUploadID(uploadID string) error {
id, ok := r.byUID[uploadID]
if !ok {
return nil
}
delete(r.tasks, id)
delete(r.byUID, uploadID)
return nil
}
// ── 测试工具 ─────────────────────────────────────────────────────────
func newTestSvc(t *testing.T) (*service, *memRepo, string) {
t.Helper()
repo := newMemRepo()
cloud := t.TempDir()
svc := newService(Options{
ChunkRoot: t.TempDir(),
CloudDir: cloud,
WebPathPrefix: "cloud",
URLPrefix: "/uploads",
Envelope: func(c *gin.Context, resp any, err error) {},
}, repo)
return svc, repo, cloud
}
func mustMD5(b []byte) string { return fmt.Sprintf("%x", md5.Sum(b)) }
func seedDoneTask(t *testing.T, svc *service, repo *memRepo, md5Str, targetDir string) *TaskInfo {
t.Helper()
task := TaskInfo{
UploadId: newUploadID(),
FileName: "old.bin",
FileMd5: md5Str,
Status: StatusDone,
TargetDir: targetDir,
StoragePath: buildStoragePath("cloud", targetDir, "old.bin"),
}
if err := repo.CreateTask(&task); err != nil {
t.Fatal(err)
}
// 物理文件落盘
phys := svc.physicalPath(task.StoragePath)
if err := os.MkdirAll(filepath.Dir(phys), 0o766); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(phys, []byte("OLD-CONTENT"), 0o644); err != nil {
t.Fatal(err)
}
return &task
}
// ── SanitizeDir ─────────────────────────────────────────────────────
func TestSanitizeDir(t *testing.T) {
ok := map[string]string{
"": "", // 根目录(固定模式)
"a": "a",
"a/": "a", // 尾随 / 归一
"a/b": "a/b",
`a\b`: "a/b",
"/abs/": "abs", // 前导 / 剥离,按相对处理
" docs / reports ": "docs / reports",
"云盘/资料": "云盘/资料",
"a\x01b": "ab", // 控制字符剔除
}
for in, want := range ok {
got, err := SanitizeDir(in)
if err != nil || got != want {
t.Errorf("SanitizeDir(%q) = %q, %v; want %q", in, got, err, want)
}
}
bad := []string{
"..", "a/../b", "a/./b", "a//b",
strings.Repeat("a/", 20) + "b", // 超深
strings.Repeat("a", MaxDirLength+1), // 超长
"a:b", "a*b", `a"b`, "a<b", "a>b", "a|b", // Windows 非法字符
}
for _, in := range bad {
if got, err := SanitizeDir(in); err == nil {
t.Errorf("SanitizeDir(%q) = %q, want error", in, got)
}
}
}
// ── init:秒传双模式 ──────────────────────────────────────────────────
func initReq(name, md5Str string, targetDir string) *InitReq {
return &InitReq{
FileName: name,
FileSize: 10,
FileMD5: md5Str,
ChunkSize: 10,
TotalChunk: 1,
TargetDir: targetDir,
}
}
func TestInitInstantHitFixedMode(t *testing.T) {
svc, repo, cloud := newTestSvc(t)
sum := mustMD5([]byte("OLD-CONTENT"))
seedDoneTask(t, svc, repo, sum, "")
resp, err := svc.init(initReq("old.bin", sum, ""), "op", 1)
if err != nil {
t.Fatal(err)
}
if resp.(map[string]any)["skipUpload"] != true {
t.Fatalf("固定模式应秒传: %v", resp)
}
if got := resp.(map[string]any)["fileUrl"]; got != "/uploads/cloud/old.bin" {
t.Fatalf("fileUrl = %v, 与 v1.0.x 行为不一致", got)
}
// 不应产生复制:cloud 根下仍只有 old.bin
entries, _ := os.ReadDir(cloud)
if len(entries) != 1 {
t.Fatalf("固定模式秒传不应复制文件, cloud 根条目数 = %d", len(entries))
}
}
func TestInitInstantHitCrossDirCopy(t *testing.T) {
svc, repo, cloud := newTestSvc(t)
sum := mustMD5([]byte("OLD-CONTENT"))
seedDoneTask(t, svc, repo, sum, "")
var hit TaskInfo
svc.opts.OnInstantHit = func(ti TaskInfo) error { hit = ti; return nil }
resp, err := svc.init(initReq("old.bin", sum, "docs/reports"), "op", 1)
if err != nil {
t.Fatal(err)
}
if got := resp.(map[string]any)["fileUrl"]; got != "/uploads/cloud/docs/reports/old.bin" {
t.Fatalf("fileUrl = %v", got)
}
if hit.StoragePath != "cloud/docs/reports/old.bin" || hit.TargetDir != "docs/reports" {
t.Fatalf("OnInstantHit 携带路径错误: %+v", hit)
}
// 物理复制存在且内容一致
data, err := os.ReadFile(filepath.Join(cloud, "docs", "reports", "old.bin"))
if err != nil || string(data) != "OLD-CONTENT" {
t.Fatalf("目标目录物理副本缺失或不一致: %v, %q", err, data)
}
}
func TestInitInstantHitFallbackWhenSourceMissing(t *testing.T) {
svc, repo, _ := newTestSvc(t)
sum := mustMD5([]byte("OLD-CONTENT"))
done := seedDoneTask(t, svc, repo, sum, "")
if err := os.Remove(svc.physicalPath(done.StoragePath)); err != nil {
t.Fatal(err)
}
resp, err := svc.init(initReq("old.bin", sum, "docs"), "op", 1)
if err != nil {
t.Fatal(err)
}
m := resp.(map[string]any)
if m["skipUpload"] != false || m["uploadId"] == "" {
t.Fatalf("旧物理文件缺失应回退正常上传: %v", m)
}
}
// ── init:断点续传目录隔离 ────────────────────────────────────────────
func TestInitResumeDirIsolation(t *testing.T) {
svc, repo, _ := newTestSvc(t)
sum := mustMD5([]byte("OLD-CONTENT"))
first, err := svc.init(initReq("old.bin", sum, "a"), "op", 1)
if err != nil {
t.Fatal(err)
}
oldUID := first.(map[string]any)["uploadId"].(string)
same, err := svc.init(initReq("old.bin", sum, "a"), "op", 1)
if err != nil {
t.Fatal(err)
}
if got := same.(map[string]any)["uploadId"]; got != oldUID {
t.Fatalf("同目录续传应复用任务: %v != %v", got, oldUID)
}
diff, err := svc.init(initReq("old.bin", sum, "b"), "op", 1)
if err != nil {
t.Fatal(err)
}
newUID := diff.(map[string]any)["uploadId"].(string)
if newUID == oldUID {
t.Fatal("目录不同应另起新任务")
}
if task, _ := repo.GetTaskByUploadID(newUID); task.TargetDir != "b" {
t.Fatalf("新任务 TargetDir 应为 b: %+v", task)
}
fixed, err := svc.init(initReq("old.bin", sum, ""), "op", 1)
if err != nil {
t.Fatal(err)
}
if got := fixed.(map[string]any)["uploadId"]; got == oldUID {
t.Fatal("固定模式与目录 a 的在途任务也应隔离")
}
}
// ── merge:落盘目标目录 ──────────────────────────────────────────────
func mergeTask(t *testing.T, svc *service, repo *memRepo, targetDir, content string) string {
t.Helper()
sum := mustMD5([]byte(content))
resp, err := svc.init(initReq("new.bin", sum, targetDir), "op", 1)
if err != nil {
t.Fatal(err)
}
uid := resp.(map[string]any)["uploadId"].(string)
dir := svc.chunkDir(uid)
if err := os.MkdirAll(dir, 0o766); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "0"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := repo.CreateChunk(&ChunkInfo{UploadId: uid, ChunkIndex: 0}); err != nil {
t.Fatal(err)
}
out, err := svc.merge(uid)
if err != nil {
t.Fatal(err)
}
if got := out.(map[string]any)["fileUrl"]; got != "/uploads/"+repo.tasks[repo.byUID[uid]].StoragePath {
t.Fatalf("fileUrl 与 storagePath 不对应: %v vs %s", got, repo.tasks[repo.byUID[uid]].StoragePath)
}
return repo.tasks[repo.byUID[uid]].StoragePath
}
func TestMergeIntoTargetDir(t *testing.T) {
svc, repo, cloud := newTestSvc(t)
sp := mergeTask(t, svc, repo, "docs/reports", "HELLO")
if sp != "cloud/docs/reports/"+filepath.Base(sp) {
t.Fatalf("storagePath 形态错误: %s", sp)
}
data, err := os.ReadFile(filepath.Join(cloud, "docs", "reports", filepath.Base(sp)))
if err != nil || string(data) != "HELLO" {
t.Fatalf("目标目录物理文件缺失或不一致: %v", err)
}
}
func TestMergeFixedModeRegression(t *testing.T) {
svc, repo, cloud := newTestSvc(t)
sp := mergeTask(t, svc, repo, "", "HELLO")
// 与 v1.0.x 逐字节一致:cloud/<nanots>_<name>,物理在 cloud 根
if !strings.HasPrefix(sp, "cloud/") || strings.Contains(strings.TrimPrefix(sp, "cloud/"), "/") {
t.Fatalf("固定模式 storagePath 应平铺在 cloud 根: %s", sp)
}
if _, err := os.Stat(filepath.Join(cloud, filepath.Base(sp))); err != nil {
t.Fatalf("固定模式物理文件应在 cloud 根: %v", err)
}
}
// ── check:目录过滤 ──────────────────────────────────────────────────
func TestCheckUploadingDirFilter(t *testing.T) {
svc, repo, _ := newTestSvc(t)
sum := mustMD5([]byte("OLD-CONTENT"))
if _, err := svc.init(initReq("old.bin", sum, "a"), "op", 1); err != nil {
t.Fatal(err)
}
uploading, err := svc.check(sum, "a")
if err != nil {
t.Fatal(err)
}
if uploading.(map[string]any)["status"] != StatusUploading {
t.Fatalf("同目录 check 应报 uploading: %v", uploading)
}
noneResp, err := svc.check(sum, "b")
if err != nil {
t.Fatal(err)
}
if noneResp.(map[string]any)["status"] != "none" {
t.Fatalf("异目录 check 应报 none: %v", noneResp)
}
// done 命中不受目录影响(init 会 copy-on-hit)
seedDoneTask(t, svc, repo, mustMD5([]byte("OTHER")), "")
doneResp, err := svc.check(mustMD5([]byte("OTHER")), "zzz")
if err != nil {
t.Fatal(err)
}
if doneResp.(map[string]any)["status"] != StatusDone {
t.Fatalf("done 命中应报 done: %v", doneResp)
}
}