// Package mover 负责"搬文件"这一步:把一批文件从老地方移到新目录。 // // 大白话理解: // - 同一个磁盘里移动 = 改个目录记录而已,瞬间完成(改名操作); // - 跨磁盘移动 = 系统不让"改名",只能老老实实把内容复制过去再删掉原来的。 // 这个包会自动判断,跨盘时偷偷用"复制+删除"顶上,调用方感觉不到区别。 // // 搬文件比较慢(尤其跨盘复制),所以用几个工人并行搬,能快不少。 package mover import ( "fmt" "io" "os" "path/filepath" "regexp" "strings" "sync" "sync/atomic" "kenaito-media-picker/internal/config" uuid "github.com/satori/go.uuid" ) // uuidPrefixRe 匹配"一长串 32 位十六进制 UUID 前缀"的文件名(如 3f2a...9c1d_photo.jpg)。 // 用它可以剥掉之前搬移留下的 UUID 前缀,还原视频的原始文件名。 var uuidPrefixRe = regexp.MustCompile(`^[0-9a-fA-F]{32}[_\.]`) // nameMu 保护"选名 + 独占创建",保证并发搬多个同名文件时不互相踩到。 // 只锁"选名"这一小段(独占创建完就放锁),真正的复制/改名在锁外进行,不影响并行效率。 var nameMu sync.Mutex // MoveJob 一次"搬文件"的任务:把 Source 这个文件搬进 TargetDir 目录。 type MoveJob struct { Source string // 文件现在在哪 TargetDir string // 要搬进哪个目录 } // Mover 文件移动器。目前不需要记录任何状态,所以是个空壳结构体。 type Mover struct{} // workerCount 并行搬文件的工人数:默认 4 个(配置 organize.workers 可调,0 表示用满 CPU)。 // 搬文件时磁盘 IO 才是瓶颈,并发太大反而把 CPU/磁盘都拖垮,给个配置让用户自行权衡。 func workerCount() int { return config.WorkerCount() } // MoveAll 把一批"搬文件任务"全局并行搬完——不管任务分散在几个目录, // 所有文件同时开搬(最多 8 个工人),这才是真正的并行。 // // 命名规则看目标目录类型: // - 图片目录(名字带「图片」):新文件名 = 一串随机 UUID + 扩展名,不保留原名; // - 视频/音频等目录:保留原文件名,重名时自动追加 (1)、(2)… 序号。 // // 返回"移动成功的 源路径 → 新位置完整路径"的对照表,方便调用方记录每个文件去哪了。 // onProgress 每搬完一个文件调一次,参数是"已累计搬完多少个"(只会涨不会退,并发安全)。 func (m *Mover) MoveAll(jobs []MoveJob, onProgress func(done int)) map[string]string { moved := make(map[string]string) if len(jobs) == 0 { return moved } // 先把所有目标目录建好(去重,每个目录只需建一次;MkdirAll 建已存在的目录也没关系) created := make(map[string]bool) for _, j := range jobs { if created[j.TargetDir] { continue } created[j.TargetDir] = true if err := os.MkdirAll(j.TargetDir, 0755); err != nil { fmt.Printf("=== 创建目录失败: %s, 错误: %v\n", j.TargetDir, err) } } // 全局并行搬:results[i] 存 jobs[i] 搬完之后的新路径(失败为空串) results := make([]string, len(jobs)) workers := workerCount() if workers > len(jobs) { workers = len(jobs) } var wg sync.WaitGroup var doneCount int32 ch := make(chan int) for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() for i := range ch { if target, ok := moveOne(jobs[i].Source, jobs[i].TargetDir); ok { results[i] = target } if onProgress != nil { onProgress(int(atomic.AddInt32(&doneCount, 1))) } } }() } for i := range jobs { ch <- i } close(ch) wg.Wait() // 把搬成功的整理成 源路径 → 新路径 的对照表 for i, target := range results { if target != "" { moved[jobs[i].Source] = target } } return moved } // moveOne 移动单个文件;跨磁盘搬不动时,自动降级为"复制 + 删除源文件"。 // 成功返回新位置的完整路径;连复制都失败返回 false。 // 注意一种特殊情况:复制成功了但删源文件失败(比如源文件被占用), // 这时源和目标会同时存在,但依然算"移动成功",返回新路径。 func moveOne(src, targetDir string) (string, bool) { // 命名规则:图片不保留原名(直接 UUID + 扩展名),视频/音频保留原文件名 keepName := !strings.Contains(targetDir, "图片") base := filepath.Base(src) if keepName { // 视频/音频:剥掉之前搬移留下的 UUID 前缀,还原真正的原文件名 base = stripUuidPrefix(base) } else { base = strings.ReplaceAll(uuid.NewV4().String(), "-", "") + filepath.Ext(base) } // 文件本来就在目标目录、名字也一样(重扫时分类没变)→ 不用搬 if filepath.Clean(filepath.Join(targetDir, base)) == filepath.Clean(src) { return src, true } // 选一个不冲突的目标名(重名自动加 (1)、(2)…),并独占占住,防止并发踩踏 target := reservePath(targetDir, base) // 先在同一个磁盘里直接改名(秒完成) if err := os.Rename(src, target); err == nil { return target, true } // 改名失败(大概率是跨磁盘)→ 复制 + 删除 if err := copyFile(src, target); err != nil { fmt.Printf("=== 复制文件失败, 源: %s, 目标: %s, 错误: %v\n", src, target, err) return "", false } if err := os.Remove(src); err != nil { fmt.Printf("=== 警告: 复制成功但删除源文件失败, 源与目标同时存在, 源: %s, 错误: %v\n", src, err) } return target, true } // stripUuidPrefix 剥掉文件名开头的 UUID 前缀,还原原始文件名。 // 比如 c3f2...7b_电影.mp4 → 电影.mp4。 // 纯 UUID + 扩展名(原始名早已丢失)时保持原样不动。 func stripUuidPrefix(base string) string { if uuidPrefixRe.MatchString(base) && base[32] == '_' { return base[33:] } return base } // reservePath 在 targetDir 里找一个不重名的文件名并"独占创建"出来占住坑位, // 返回完整路径。重名时自动在扩展名前加序号:原名 (1).ext、原名 (2).ext…… // 独占创建保证并发搬同名文件时,各自挑到不同的名字,不会互相覆盖。 func reservePath(targetDir, base string) string { nameMu.Lock() defer nameMu.Unlock() ext := filepath.Ext(base) stem := strings.TrimSuffix(base, ext) for i := 0; ; i++ { name := base if i > 0 { name = fmt.Sprintf("%s (%d)%s", stem, i, ext) } p := filepath.Join(targetDir, name) f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err == nil { f.Close() return p } // 文件已存在 → 换个序号再试;其他错误(权限等)就当占住失败,原样返回兜底 if !os.IsExist(err) { return p } } } // 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 }