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
+98
View File
@@ -0,0 +1,98 @@
package fileupload
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// 目标目录(targetDir)约束:相对存储根(Options.CloudDir)。
const (
MaxDirLength = 500 // 总长度上限
MaxDirDepth = 16 // 层级深度上限
)
// SanitizeDir 规范客户端传入的目标目录,返回以 / 分隔的相对路径;
// 空串表示根目录(固定路径模式)。防路径穿越规则:
// - 反斜杠归一为 /,剔除控制字符,去首尾 /,按段校验;
// - 拒绝 . / .. 段、空段、超长、超深、段内 Windows 非法字符(: * ? " < > |);
// - 一律按相对路径处理(前导 / 仅剥离,不表示绝对路径)。
func SanitizeDir(dir string) (string, error) {
dir = strings.Map(func(r rune) rune {
if r < 0x20 {
return -1
}
return r
}, strings.ReplaceAll(dir, "\\", "/"))
dir = strings.TrimSpace(dir)
dir = strings.Trim(dir, "/")
if dir == "" {
return "", nil
}
if len(dir) > MaxDirLength {
return "", fmt.Errorf("目标目录超过长度上限 %d", MaxDirLength)
}
segs := strings.Split(dir, "/")
if len(segs) > MaxDirDepth {
return "", fmt.Errorf("目标目录超过层级上限 %d", MaxDirDepth)
}
for _, seg := range segs {
switch seg {
case "", ".", "..":
return "", fmt.Errorf("目标目录含非法段 %q", seg)
}
if strings.ContainsAny(seg, `:*?"<>|`) {
return "", fmt.Errorf("目标目录段 %q 含非法字符", seg)
}
}
return strings.Join(segs, "/"), nil
}
// buildStoragePath 拼 storagePath(Web 相对路径):
// WebPathPrefix + "/" + targetDir + "/" + 产物名(前缀与目录为空时跳过对应段)。
func buildStoragePath(webPathPrefix, targetDir, storageName string) string {
parts := make([]string, 0, 3)
if webPathPrefix != "" {
parts = append(parts, webPathPrefix)
}
if targetDir != "" {
parts = append(parts, targetDir)
}
parts = append(parts, storageName)
return strings.Join(parts, "/")
}
// physicalPath 由 storagePath(Web 相对路径)反解物理落盘路径:
// 剥离 WebPathPrefix 后相对 CloudDir。秒传复制旧文件时用。
func (s *service) physicalPath(storagePath string) string {
rel := strings.ReplaceAll(storagePath, "\\", "/")
if s.opts.WebPathPrefix != "" && strings.HasPrefix(rel, s.opts.WebPathPrefix+"/") {
rel = rel[len(s.opts.WebPathPrefix)+1:]
}
return filepath.Join(s.opts.CloudDir, filepath.FromSlash(rel))
}
// copyFile 文件级复制,失败时清理半成品。
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err = io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(dst)
return err
}
if err = out.Close(); err != nil {
_ = os.Remove(dst)
return err
}
return nil
}