feat: 断点续传与色情识别提速/修复,支持 Linux 交叉编译

- feat: 新增续传表 scan_log,重扫跳过已归类文件(尺寸/头尾指纹校验)
- fix: 色情识别记录批量入库超 SQLite 999 变量上限,改每 100 条一批
- perf: NudeNet 检测改为多线程推理池(每线程独立模型实例)
- perf: 明暗分析 JPEG(YCbCr) 快路径;SQLite 读并发放开;加阶段耗时统计
- fix: 前端色情识别状态由后端如实上报(配置开关+检测服务是否起来)
- feat: ffprobe 内嵌按平台拆分,Linux 构建回退系统 ffprobe,支持交叉编译
- docs: README 补充断点续传与 CentOS 交叉编译步骤
- chore: organize.workers 默认 8
This commit is contained in:
2025-06-19 21:07:35 +08:00
parent ea134afa27
commit 957dfe1134
18 changed files with 908 additions and 111 deletions
+64 -4
View File
@@ -13,6 +13,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
@@ -22,6 +23,14 @@ import (
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 // 文件现在在哪
@@ -40,7 +49,10 @@ func workerCount() int {
// MoveAll 把一批"搬文件任务"全局并行搬完——不管任务分散在几个目录,
// 所有文件同时开搬(最多 8 个工人),这才是真正的并行。
//
// 目标文件名会加一长串随机前缀(uuid),这样即使两个文件同名也不会互相覆盖。
// 命名规则看目标目录类型:
// - 图片目录(名字带「图片」):新文件名 = 一串随机 UUID + 扩展名,不保留原名;
// - 视频/音频等目录:保留原文件名,重名时自动追加 (1)、(2)… 序号。
//
// 返回"移动成功的 源路径 → 新位置完整路径"的对照表,方便调用方记录每个文件去哪了。
// onProgress 每搬完一个文件调一次,参数是"已累计搬完多少个"(只会涨不会退,并发安全)。
func (m *Mover) MoveAll(jobs []MoveJob, onProgress func(done int)) map[string]string {
@@ -103,9 +115,21 @@ func (m *Mover) MoveAll(jobs []MoveJob, onProgress func(done int)) map[string]st
// 注意一种特殊情况:复制成功了但删源文件失败(比如源文件被占用),
// 这时源和目标会同时存在,但依然算"移动成功",返回新路径。
func moveOne(src, targetDir string) (string, bool) {
// 新文件名 = 随机前缀 + 原文件名,防止重名覆盖
fileName := strings.ReplaceAll(uuid.NewV4().String(), "-", "") + "_" + filepath.Base(src)
target := filepath.Join(targetDir, fileName)
// 命名规则:图片不保留原名(直接 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
@@ -121,6 +145,42 @@ func moveOne(src, targetDir string) (string, bool) {
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 {