diff --git a/README.md b/README.md index 8879f6a..b9f8aec 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - **EXIF 旋转修正**:带旋转标记的竖拍图自动处理——有 ffmpeg 时物理转正像素,否则至少按显示方向(宽高对调)归类;可在配置中整体关闭 - **色情识别(可选)**:本机 Python + NudeNet 离线鉴黄,命中的图片移入 `图片_敏感_*` 目录并记录检测分,Web 端可逐张预览复核 - **异常兜底**:读取失败、不支持格式自动归入「读取异常」「已忽略」目录;GIF、PSD 单独归类 +- **断点续传**:每个文件归类结果实时写入续传表(`scan_log`),扫描中断/出错后重跑时,已归类过的文件直接复用上次分类,不再重新探测尺寸 / 算 MD5 / 分析明暗 / 色情识别 - **媒体信息入库**:自动把文件 MD5 与完整媒体信息(类型 / 宽高 / 时长 / 编码 / 容器等)写入 `media-info.db`(SQLite),支持筛选查询与去重 - **Web 管理界面**:页面点击触发扫描整理,WebSocket 实时推送进度与运行日志;支持中止 - **并行处理**:探测 / 归类 / 移动各阶段并发 worker 数可配置(默认 4) @@ -104,6 +105,16 @@ lightScore = 亮度 > 175 的像素占比 与数据库中已有记录 **MD5 相同且源路径相同** 的文件视为"同一文件被重复扫到",原地忽略(不移动、不重复入库)。MD5 相同但路径不同视为不同文件,照常整理。 +### 断点续传(扫描表) + +每次归类一个文件,程序就会把它「源路径 → 目标目录」记进数据库的续传表(`scan_log`)。之后**扫描中断、出错、甚至中途手动中止再重跑**时: + +- 已归类过且内容没变的文件 → **跳过全部探测/识别(尺寸、MD5、明暗、色情识别都不再重做)**,直接复用上次的目标目录,没搬走的这次搬走; +- 内容变化(文件大小或头尾指纹变了)→ 当作新文件重新探测归类; +- 大文件(>1MB)用「大小 + 头尾 256KB 指纹」校验内容,小文件只比对大小。 + +所以重扫基本只花在"剩余没处理的文件"上,不会把整批几小时的工作重做一遍。 + ### 色情图片识别(可选) 基于本机 **Python + NudeNet** 的离线鉴黄,判为色情的图片移入 `图片_敏感_*` 分类目录(明暗子目录照常保留,如 `图片_敏感_横屏_1080P/bright_20`),并可在 Web「色情识别」页面查看每张图的检测分、路径与预览。 @@ -124,6 +135,40 @@ sh build.sh - 依赖 Go 1.25.0+(`go.mod` 指定) - `build.sh` 先构建前端,再 `go build` 输出 `kenaito-media-picker-release.exe`(upx 压缩已注释,可自行启用) - 前端产物(`frontend/dist`)由 `go:embed` 打进二进制;`internal/probe/bin/ffprobe.exe`(Windows 版,约 145MB)已随仓库入库并内嵌 +- 项目使用纯 Go 的 SQLite 驱动(无 CGO),因此**可以跨平台交叉编译** + +### 在 Windows 10 x64 上交叉编译 CentOS x64(Linux amd64)可执行程序 + +```shell +# 方式一:Git Bash / MSYS 终端 +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o kenaito-media-picker-linux-amd64 . + +# 方式二:PowerShell +$env:CGO_ENABLED="0"; $env:GOOS="linux"; $env:GOARCH="amd64" +go build -o kenaito-media-picker-linux-amd64 . +# 编完恢复环境变量 +Remove-Item Env:CGO_ENABLED,Env:GOOS,Env:GOARCH +``` + +> `CGO_ENABLED=0` 很关键:本项目 SQLite 用的是纯 Go 驱动,关掉 CGO 才能交叉编译;若用默认开启 CGO 会尝试调 Linux 的 C 编译器而失败。 + +**把二进制拷到 CentOS 后,还要注意:** + +1. **ffprobe(视频/音频探测)**:Linux 构建**不内嵌** ffprobe(仓库只带了 Windows 版),程序会回退用系统 PATH 里的 `ffprobe`。CentOS 上先装: + ```shell + # CentOS/RHEL 需要先装 EPEL 再装 ffmpeg(自带 ffprobe) + sudo yum install -y epel-release + sudo yum install -y ffmpeg + # 验证:ffprobe -version + ``` + 没装 ffprobe 时视频/音频整理会跳过,图片照常处理。 +2. **色情识别(可选)**:Linux 上同样要装 Python 与 nudenet,并在 `application.properties` 里配 `nsfw.python`(如 `/usr/bin/python3`): + ```shell + sudo yum install -y python3 + pip3 install nudenet + ``` + 模型文件 `lib/320n.onnx` 需与二进制放在同一目录(没有会自动下载)。 +3. 其余步骤与 Windows 一致:`application.properties`、`media-info.db` 随运行目录自动创建。 ### 前端开发模式(无需重复构建) diff --git a/application.properties b/application.properties index 8ebb595..fcd5c40 100644 --- a/application.properties +++ b/application.properties @@ -11,7 +11,7 @@ ffmpeg.path= # false=完全不处理:图片按存储方向/尺寸归类,不做任何旋转相关改动 organize.rotateImages=false # 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数) -organize.workers=4 +organize.workers=8 # 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet) # true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认) # 模型:优先加载程序运行目录下 lib/320n.onnx(推荐随程序分发,离线可用); diff --git a/frontend/src/views/NsfwList.vue b/frontend/src/views/NsfwList.vue index f2c4830..ed424cb 100644 --- a/frontend/src/views/NsfwList.vue +++ b/frontend/src/views/NsfwList.vue @@ -3,7 +3,8 @@
色情识别
- 识别功能未开启:请在 application.properties 设 nsfw.enabled=true 并重新扫描 + 识别功能未开启:请在 application.properties 设 nsfw.enabled=true 并重新扫描 + 识别已配置开启,但最近一次扫描时检测服务没起来:请确认本机已安装 python 和 nudenet,再重新扫描 刷新
@@ -58,7 +59,7 @@ + :description="emptyDesc"/> @@ -76,10 +77,21 @@ export default { return { loading: false, list: [], - nsfwConfigured: true // 后端没开启识别时列表为空,这里只做提示不做强校验 + nsfwEnabled: true, // 配置里是否开了 nsfw.enabled + nsfwDetector: true, // 最近一次扫描时检测服务是否真的起来了 + nsfwConfigured: true // 上两者都满足才算"识别可用" } }, - mounted() { + computed: { + // 空列表时的提示文案:识别可用说明是"没命中",否则是"没开启/没起来" + emptyDesc() { + if (this.nsfwConfigured) { + return '识别已开启,最近一次扫描没有判出色情图片。' + } + return '暂无记录。开启 nsfw.enabled 并确保检测服务可用后重新扫描,判为色情的图片会出现在这里。' + } + }, + mounted() { this.loadList() }, methods: { @@ -88,7 +100,10 @@ export default { try { const res = await request.get('/api/nsfw/list') this.list = res.data.list || [] - this.nsfwConfigured = (res.data.total || 0) > 0 || this.list.length > 0 + // 后端真实上报:配置是否开启、检测服务上次是否真的起来了 + this.nsfwEnabled = res.data.nsfwEnabled !== false + this.nsfwDetector = res.data.nsfwDetector === true + this.nsfwConfigured = this.nsfwEnabled && this.nsfwDetector } finally { this.loading = false } diff --git a/internal/api/nsfw_controller.go b/internal/api/nsfw_controller.go index 19302b2..b2e8f66 100644 --- a/internal/api/nsfw_controller.go +++ b/internal/api/nsfw_controller.go @@ -10,9 +10,15 @@ import ( "github.com/gin-gonic/gin" + "kenaito-media-picker/internal/config" "kenaito-media-picker/internal/store" ) +// nsfwDetectorReadyKey 记录"最近一次扫描时色情检测服务是否真的起来了"(存 AppConfig)。 +// 配置开了但检测器没起来(没装 python/模型下载失败等)也能区分出来,方便前端提示。 +// 由 scan 流程在服务启动成功/失败时写 1/0。 +const nsfwDetectorReadyKey = "nsfw.detectorReady" + // NsfwController 色情识别相关接口的"窗口"。 type NsfwController struct { store *store.Store // 数据库(只读) @@ -55,7 +61,13 @@ func (c *NsfwController) List(ctx *gin.Context) { CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), }) } - Success(ctx, map[string]interface{}{"list": views, "total": len(views)}) + Success(ctx, map[string]interface{}{ + "list": views, + "total": len(views), + "nsfwEnabled": config.GetAppConfig().NSFWEnabled, + // 没扫描过(空)或上次成功(1)都算可用;只有明确记过失败(0)才提示检测服务没起来 + "nsfwDetector": c.store == nil || c.store.GetConfig(nsfwDetectorReadyKey) != "0", + }) } // Preview 处理 GET /api/nsfw/preview?path=xxx:把指定图片文件返回给浏览器做预览。 diff --git a/internal/mover/mover.go b/internal/mover/mover.go index 17d5706..e040b83 100644 --- a/internal/mover/mover.go +++ b/internal/mover/mover.go @@ -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 { diff --git a/internal/nsfw/detector.go b/internal/nsfw/detector.go index 122b2f1..f1eb0f8 100644 --- a/internal/nsfw/detector.go +++ b/internal/nsfw/detector.go @@ -15,6 +15,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" @@ -48,17 +49,20 @@ const requestTimeout = 30 * time.Second // Detector 封装一个本地 python 服务的客户端。 // 多个探测协程会并发调 Detect,底层 HTTP 客户端线程安全; -// 服务端单线程处理,请求自己排队,不用额外加锁。 +// 服务端用固定大小线程池并行推理(每个 worker 一份独立模型实例),请求不必排队。 type Detector struct { url string // 本地服务地址,如 http://127.0.0.1:54321 threshold float64 // 判定阈值 0~1 client *http.Client cmd *exec.Cmd // python 进程句柄(Stop 时杀掉) scriptDir string // 临时脚本所在目录(Stop 时清理) - mu sync.Mutex // 保护下面的关闭状态 + python string // python 可执行文件路径(服务重启时要用) + modelDir string // 模型目录 lib(服务重启时要用) + mu sync.Mutex // 保护 stopped / 进程句柄 stopped bool // 已经 Stop 过了 cancel context.CancelFunc done chan struct{} // 进程退出时关闭(Wait 协程负责) + restartMu sync.Mutex // 串行化重启:同一时刻只一个 goroutine 重启,其余排队等 } // Start 拉起本地 python 检测服务并等它就绪。 @@ -87,13 +91,6 @@ func Start(pythonPath string, threshold float64) (*Detector, error) { return nil, fmt.Errorf("写入检测脚本失败: %v", err) } - // 找一个空闲端口 - port, err := freePort() - if err != nil { - _ = os.RemoveAll(tmpDir) - return nil, fmt.Errorf("找空闲端口失败: %v", err) - } - // 模型目录:程序运行目录下的 lib(和 application.properties / media-info.db 同一根目录)。 // 服务脚本优先加载 lib/320n.onnx;本地没有会自己下载到那里,下载失败再回退用 pip 自带模型。 modelDir := "" @@ -101,27 +98,51 @@ func Start(pythonPath string, threshold float64) (*Detector, error) { modelDir = filepath.Join(wd, "lib") } - // 拉起服务。标准输出/错误接到程序自身的输出,方便看 python 报错。 + d := &Detector{ + threshold: threshold, + client: &http.Client{Timeout: requestTimeout}, + python: py, + modelDir: modelDir, + scriptDir: tmpDir, + } + + // 拉起服务(同样一套逻辑也用于崩溃后的重启) + if err := d.launch(); err != nil { + _ = os.RemoveAll(tmpDir) + return nil, err + } + return d, nil +} + +// launch 启动(或重启)python 检测服务,并等它就绪(模型加载完成)。 +// 调用方需要保证持有 mu,且 stopped 为 false。 +func (d *Detector) launch() error { + // 先把上一次的进程收拾掉(首次启动时没有,直接跳过) + d.teardown() + + // 找一个空闲端口 + port, err := freePort() + if err != nil { + return fmt.Errorf("找空闲端口失败: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, py, scriptPath, fmt.Sprintf("%d", port), modelDir) + cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir) + // 标准输出/错误都接到程序自身的输出: + // - 扫描期间 os.Stdout 会被接管进"运行日志",这样 python 的报错(崩溃原因)也能在前端看到 + // - 方便排查服务为什么挂掉 cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stderr = os.Stdout if err := cmd.Start(); err != nil { cancel() - _ = os.RemoveAll(tmpDir) - return nil, fmt.Errorf("启动检测服务失败: %v", err) + return fmt.Errorf("启动检测服务失败: %v", err) } done := make(chan struct{}) - d := &Detector{ - url: fmt.Sprintf("http://127.0.0.1:%d", port), - threshold: threshold, - client: &http.Client{Timeout: requestTimeout}, - cmd: cmd, - scriptDir: tmpDir, - cancel: cancel, - done: done, - } + d.url = fmt.Sprintf("http://127.0.0.1:%d", port) + d.cmd = cmd + d.cancel = cancel + d.done = done // 后台等进程退出:ProcessState 要 Wait 之后才有值,这里用 done 通知, // 这样 waitReady 能及时发现"python 没装 nudenet 直接退掉"的情况。 go func() { @@ -131,10 +152,32 @@ func Start(pythonPath string, threshold float64) (*Detector, error) { // 等服务就绪(模型加载完成)。就绪前先探测 /health。 if err := d.waitReady(); err != nil { - d.Stop() - return nil, err + d.teardown() + return err } - return d, nil + return nil +} + +// teardown 停掉当前 python 进程并清理句柄(不删临时目录)。 +// 调用方需要保证持有 mu。进程可能已经死了(崩溃),kill 失败也无所谓。 +func (d *Detector) teardown() { + if d.cancel != nil { + d.cancel() + d.cancel = nil + } + if d.cmd != nil && d.cmd.Process != nil { + _ = d.cmd.Process.Kill() + } + if d.done != nil { + // 等 Wait 协程回收进程,避免僵尸进程;进程已死则立刻返回。 + // 等超过 3 秒就放弃,防止极端情况下卡死重启流程。 + select { + case <-d.done: + case <-time.After(3 * time.Second): + } + d.done = nil + } + d.cmd = nil } // waitReady 轮询本地服务,直到能正常应答。 @@ -168,14 +211,73 @@ func (d *Detector) waitReady() error { // - score:命中的敏感类里最高分数(没有命中为 0) // - nsfw:是否判定为色情(有敏感类且分数 >= 阈值) // - err:检测失败(服务挂了/图读不出等),由调用方兜底按未命中处理 +// +// 服务失联(连接被拒,多半是 python 进程在处理某张图时崩了)时,会串行化地 +// 重启服务并重试一次:多个并发 worker 里只有一个负责重启,其余排队,等重启完 +// 也重试自己的图——保证服务短暂挂掉不会漏掉一整批图片。 func (d *Detector) Detect(path string) (score float64, nsfw bool, err error) { + score, nsfw, err = d.detectOnce(path) + if err == nil { + return score, nsfw, nil + } + // 只有"发请求都失败"(连接被拒/重置,说明服务失联)才走重启; + // 服务正常返回的明确错误(如某张图读不了)不重启。 + var ce *connError + if !errors.As(err, &ce) { + return score, nsfw, err + } + + d.restartMu.Lock() + defer d.restartMu.Unlock() + + if d.isStopped() { + return 0, false, err + } + // 可能排在前面的 goroutine 已经把服务重启好了:先探活,活着就直接重试,别反复重启 + if d.healthy() { + return d.detectOnce(path) + } + fmt.Printf("=== 色情检测服务失联(%v), 正在重启…\n", ce.err) + if rerr := d.launch(); rerr != nil { + return 0, false, fmt.Errorf("检测服务重启失败: %v", rerr) + } + fmt.Println("=== 色情检测服务已重启,继续识别") + return d.detectOnce(path) +} + +// healthy 快速探活:本地服务是否还能正常应答(带短超时,避免拖慢扫描)。 +func (d *Detector) healthy() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url+"/health", nil) + if err != nil { + return false + } + resp, err := d.client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// isStopped 判断检测器是否已被 Stop(线程安全)。 +func (d *Detector) isStopped() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.stopped +} + +// detectOnce 发一次检测请求(不带重启逻辑)。 +func (d *Detector) detectOnce(path string) (score float64, nsfw bool, err error) { body, _ := json.Marshal(map[string]interface{}{ "path": path, "threshold": d.threshold, }) resp, err := d.client.Post(d.url+"/detect", "application/json", bytes.NewReader(body)) if err != nil { - return 0, false, err + // 打上"连接失败"标记,供 Detect 判断是否要重启 + return 0, false, &connError{err} } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) @@ -197,27 +299,21 @@ func (d *Detector) Detect(path string) (score float64, nsfw bool, err error) { return out.Score, out.Nsfw, nil } +// connError 标记"请求根本没发出去/连不上本地服务"的错误(服务失联)。 +type connError struct{ err error } + +func (e *connError) Error() string { return e.err.Error() } +func (e *connError) Unwrap() error { return e.err } + // Stop 停掉检测服务并清理临时文件。可以安全地重复调用。 func (d *Detector) Stop() { d.mu.Lock() + defer d.mu.Unlock() if d.stopped { - d.mu.Unlock() return } d.stopped = true - d.mu.Unlock() - - if d.cancel != nil { - d.cancel() - } - if d.cmd != nil && d.cmd.Process != nil { - // 进程活着才 kill;kill 失败也无所谓,服务很快就没了 - _ = d.cmd.Process.Kill() - } - if d.done != nil { - // 等 Wait 协程回收进程,避免僵尸进程;进程已死则立刻返回 - <-d.done - } + d.teardown() if d.scriptDir != "" { _ = os.RemoveAll(d.scriptDir) } diff --git a/internal/nsfw/detector_restart_test.go b/internal/nsfw/detector_restart_test.go new file mode 100644 index 0000000..8641ae7 --- /dev/null +++ b/internal/nsfw/detector_restart_test.go @@ -0,0 +1,47 @@ +package nsfw + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + "time" +) + +// 1x1 透明 PNG,当测试图片用(NudeNet 会自己缩放)。 +const testPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + +// TestDetectRestart 验证:python 检测服务中途崩溃后,Detect 会自动重启并恢复正常。 +func TestDetectRestart(t *testing.T) { + py := resolvePython("") + if py == "" { + t.Skip("无 python") + } + img := filepath.Join(t.TempDir(), "test.png") + if data, err := base64.StdEncoding.DecodeString(testPNG); err != nil { + t.Fatalf("解码测试图片失败: %v", err) + } else if err := os.WriteFile(img, data, 0644); err != nil { + t.Fatalf("写测试图片失败: %v", err) + } + + d, err := Start(py, 0.5) + if err != nil { + t.Fatalf("Start: %v", err) + } + defer d.Stop() + + if _, _, err := d.Detect(img); err != nil { + t.Fatalf("崩溃前检测失败: %v", err) + } + + // 直接杀掉 python 进程,模拟处理某张图时崩溃 + if d.cmd != nil && d.cmd.Process != nil { + _ = d.cmd.Process.Kill() + } + time.Sleep(2 * time.Second) // 等进程退出、端口释放 + + if _, _, err := d.Detect(img); err != nil { + t.Fatalf("崩溃后检测失败(应自动重启): %v", err) + } + t.Log("重启后检测成功") +} diff --git a/internal/nsfw/server.py b/internal/nsfw/server.py index 0df3a49..c322e98 100644 --- a/internal/nsfw/server.py +++ b/internal/nsfw/server.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- """NudeNet 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。 -只依赖 Python 标准库 + nudenet 包,模型在进程里只加载一次。 -收到 /detect 就把图片喂给 NudeDetector,返回结果 JSON。 +只依赖 Python 标准库 + nudenet 包。 +onnxruntime 的 InferenceSession 不是线程安全的,同一个实例并发推理会崩, +所以这里用一个固定大小的推理线程池:每个 worker 线程各持一份独立模型实例, +N 个请求可以真·并行推理。收到 /detect 就把图片喂给 NudeDetector,返回结果 JSON。 模型加载优先级(模型放在 <程序运行目录>/lib/320n.onnx): 1. 本地已有该文件 → 直接加载(离线可用,适合随程序分发); @@ -15,12 +17,15 @@ Windows 超长路径说明:OpenCV 的 imread 走老式 C API,路径超过 26 再把字节直接交给 NudeNet(v3 的 detect 支持 bytes)——从内存解码,彻底 绕开路径限制,同时兼容老版 v2(只收路径时落到临时短路径文件再试)。 """ +import concurrent.futures import json import os +import shutil import sys import tempfile +import threading import urllib.request -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from nudenet import NudeDetector @@ -49,6 +54,17 @@ MODEL_DIR = sys.argv[2] if len(sys.argv) > 2 else "" _detector = None +# 推理并发数:onnxruntime 的 InferenceSession.run() 不是线程安全的, +# 同一个实例并发推理会崩,所以每个 worker 线程各持有一个独立实例(NudeDetector), +# N 个 worker 即可真·并行推理。默认 4,可用环境变量 KM_NSFW_WORKERS 调整。 +INFER_WORKERS = int(os.environ.get("KM_NSFW_WORKERS", "4") or "4") +if INFER_WORKERS < 1: + INFER_WORKERS = 1 + +_executor = None # 推理线程池(固定 N 个 worker,模型各持一份) +_thread_local = threading.local() # 每个 worker 线程自己的检测器 +_MODEL_PATH = None # 启动时解析好的模型路径(None 表示用包自带模型) + def _looks_like_model(path): """粗略校验文件是不是真 ONNX 模型:开头不是 HTML、大小不小于 1MB(320n 模型约 11.6MB)。 @@ -65,6 +81,13 @@ def _looks_like_model(path): return False +def _download_model(local): + """带超时地把模型下载到 local。urlretrieve 没有超时,网络挂起会卡死整个服务。""" + req = urllib.request.Request(MODEL_URL) + with urllib.request.urlopen(req, timeout=30) as resp, open(local, "wb") as f: + shutil.copyfileobj(resp, f) + + def _ensure_model(model_dir): """返回要加载的模型路径;返回 None 表示用 nudenet 包自带的模型。""" if not model_dir: @@ -73,11 +96,11 @@ def _ensure_model(model_dir): if os.path.isfile(local) and _looks_like_model(local): print("=== 使用本地模型: %s" % local, flush=True) return local - # 本地没有(或文件无效)→ 下载到 lib 目录(仅第一次需要网络) + # 本地没有(或文件无效)→ 下载到 lib 目录(仅第一次需要网络,带 30 秒超时) try: os.makedirs(model_dir, exist_ok=True) print("=== 本地未找到有效模型, 正在下载: %s" % local, flush=True) - urllib.request.urlretrieve(MODEL_URL, local) + _download_model(local) if _looks_like_model(local): print("=== 模型下载完成: %s" % local, flush=True) return local @@ -93,22 +116,33 @@ def _ensure_model(model_dir): def get_detector(): - global _detector + """确保模型已就绪(下载/加载),返回一个检测器实例。 + + 启动时调用一次:把模型下载/解析好,/health 就绪即代表模型可用。 + 真正推理用的独立实例由 _new_detector() 按线程各建一份,共享这份模型路径。 + """ + global _detector, _MODEL_PATH if _detector is None: - mp = _ensure_model(MODEL_DIR) - try: - _detector = NudeDetector(model_path=mp) - except Exception as exc: # noqa: BLE001 - 指定路径加载失败就回退包自带模型,别崩 - print("=== 本地模型加载失败(%s), 回退用包自带模型" % exc, flush=True) - if mp: - try: - os.remove(mp) - except OSError: - pass - _detector = NudeDetector() + _MODEL_PATH = _ensure_model(MODEL_DIR) + _detector = _new_detector() return _detector +def _new_detector(): + """创建一份独立检测器实例(每个推理 worker 各持一份,可并行推理)。""" + mp = _MODEL_PATH + try: + return NudeDetector(model_path=mp) if mp else NudeDetector() + except Exception as exc: # noqa: BLE001 - 指定路径加载失败就回退包自带模型,别崩 + print("=== 本地模型加载失败(%s), 回退用包自带模型" % exc, flush=True) + if mp: + try: + os.remove(mp) + except OSError: + pass + return NudeDetector() + + def _longpath(path): """Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。""" if os.name != "nt": @@ -136,7 +170,10 @@ def _read_bytes(path): def _detect_bytes(det, data, path): - """把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。""" + """把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。 + + 用线程自己的检测器实例推理,各实例互不干扰,可以真·并行。 + """ try: return det.detect(data) except (TypeError, AttributeError): @@ -154,6 +191,19 @@ def _detect_bytes(det, data, path): pass +def _infer(path): + """整个推理任务(读文件 + 检测),提交到固定线程池执行。 + + 读文件在池外并行进行;推理用线程各自的独立检测器,N 个 worker 真·并行。 + """ + data = _read_bytes(path) + det = getattr(_thread_local, "detector", None) + if det is None: + det = _new_detector() + _thread_local.detector = det + return _detect_bytes(det, data, path) + + class Handler(BaseHTTPRequestHandler): def _send(self, obj, status=200): data = json.dumps(obj, ensure_ascii=False).encode("utf-8") @@ -179,8 +229,8 @@ class Handler(BaseHTTPRequestHandler): threshold = float(body.get("threshold", 0.5)) if not path: return self._send({"ok": False, "error": "缺少图片路径"}) - data = _read_bytes(path) - detections = _detect_bytes(get_detector(), data, path) + # 丢给推理线程池:读文件并行、推理由 N 个 worker 并行处理 + detections = _executor.submit(_infer, path).result() except Exception as exc: # noqa: BLE001 - 服务端任何异常都要回给 Go,别让进程崩 return self._send({"ok": False, "error": str(exc)}) @@ -200,9 +250,19 @@ class Handler(BaseHTTPRequestHandler): pass +# 多线程接收连接 + 大监听队列:Go 侧会并发发请求(worker 数可到 8), +# 单线程 HTTPServer 默认 backlog 只有 5,并发一高连接就被内核拒绝(报 10061 连接拒绝)。 +# ThreadingHTTPServer 让连接即时被 accept;推理再交给固定线程池并行执行 +# (每个 worker 一份独立模型实例,onnxruntime 同一实例并发推理会崩)。 +class Server(ThreadingHTTPServer): + request_queue_size = 128 + + if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 0 # 启动前先把模型准备好(本地加载 / 下载 / 回退),/health 就绪即代表模型可用, # 这样 Go 侧 waitReady 等到的就是"模型已就绪"的服务。 get_detector() - HTTPServer(("127.0.0.1", port), Handler).serve_forever() \ No newline at end of file + # 固定大小推理线程池:每个 worker 线程持一份独立模型实例,可并行推理 + _executor = concurrent.futures.ThreadPoolExecutor(max_workers=INFER_WORKERS) + Server(("127.0.0.1", port), Handler).serve_forever() \ No newline at end of file diff --git a/internal/organize/organize.go b/internal/organize/organize.go index fe9fa2b..8168e0f 100644 --- a/internal/organize/organize.go +++ b/internal/organize/organize.go @@ -18,6 +18,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "kenaito-media-picker/internal/classify" "kenaito-media-picker/internal/config" @@ -48,6 +49,13 @@ const ( dirAudio = "音频" // 音频统一放这一个文件夹 ) +// nsfwProgressInterval 色情识别每隔多少张图打一行进度,让前端能看到识别在推进 +// (逐张打太吵,日志还会被 trim 掉一半)。 +const nsfwProgressInterval = 200 + +// scanLogFlushInterval 续传记录攒够多少条就落一次库(中断/出错时尽量少丢已归类的结果)。 +const scanLogFlushInterval = 200 + // ============================ 整理器主体 ============================ // Organizer 整理器。它的工作方式: @@ -69,12 +77,24 @@ type Organizer struct { onProgress func(phase string, done, total int) pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写) pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写) + resume map[string]store.ScanLog // 续传表:源路径 → 上次归类结果(重扫跳过重新探测) + resumeSkip int32 // 本次续传跳过的文件数(并行累计) + pendingScan []store.ScanLog // 待写入续传表的记录(攒一批再批量写) + nsfwChecked int32 // 色情识别已检查的图片数(并行累计,只涨不跌) + nsfwHits int32 // 色情识别命中的图片数(并行累计) + // 阶段耗时统计(并行 worker 累计,单位纳秒,只用于日志诊断瓶颈) + tImageProbe int64 // 图片尺寸探测总耗时 + tImgMd5 int64 // 图片 MD5/指纹计算总耗时 + tBright int64 // 明暗分析总耗时 + tNsfw int64 // 色情识别总耗时 + tMediaProbe int64 // 媒体探测(ffprobe)总耗时 + tMediaMd5 int64 // 媒体(视频/音频) MD5/指纹计算总耗时 } // New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。 // ctx 可以为 nil——不提供中止能力,任务跑到完为止。 func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context.Context) *Organizer { - return &Organizer{ + org := &Organizer{ pickerDir: pickerDir, ffprobePath: ffprobePath, ffmpegPath: ffmpegPath, @@ -84,6 +104,16 @@ func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context rotateImages: true, // 默认处理 EXIF 旋转;关掉用 SetRotateImages(false) buckets: make(map[string][]string), } + // 加载续传表:上次归类过的文件,这次跳过重新探测,直接复用目标目录 + if st != nil { + if m, err := st.LoadScanLog(); err != nil { + fmt.Printf("=== 加载续传记录失败: %v\n", err) + } else if len(m) > 0 { + org.resume = m + fmt.Printf("=== 续传: 上次已归类 %d 个文件,本次将跳过它们的重新探测/识别\n", len(m)) + } + } + return org } // SetProgress 设置进度回调。回调参数: @@ -166,15 +196,76 @@ func runParallel[T any, R any](ctx context.Context, items []T, fn func(T) R, onP // ============================ 分类与入库小工具 ============================ -// add 把一个文件记到某个目标目录的名下(先攒着,最后统一搬)。 +// add 把一个文件记到某个目标目录(相对分类名)的名下(先攒着,最后统一搬)。 func (o *Organizer) add(f scanner.File, subDir string) { - dir := filepath.Join(o.pickerDir, subDir) + o.addToDir(f, filepath.Join(o.pickerDir, subDir)) +} + +// addToDir 把一个文件记到某个目标目录(完整路径)的名下。 +func (o *Organizer) addToDir(f scanner.File, dir string) { if _, ok := o.buckets[dir]; !ok { o.order = append(o.order, dir) } o.buckets[dir] = append(o.buckets[dir], f.Path) } +// addClassified 归档一个文件并记进续传表:把"源路径 → 目标目录"写进 scan_log, +// 这样下次重扫时这个文件直接复用目标目录,不再重新探测/识别。 +func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, headMd5, tailMd5 string) { + o.add(f, subDir) + if o.store == nil { + return + } + o.pendingScan = append(o.pendingScan, store.ScanLog{ + SourcePath: f.Path, + TargetDir: filepath.Join(o.pickerDir, subDir), + FileSize: size, + HeadMd5: headMd5, + TailMd5: tailMd5, + }) + if len(o.pendingScan) >= scanLogFlushInterval { + o.flushScanLog() + } +} + +// matchResume 判断一个文件上次是否已归类过,且内容没变。 +// 命中返回上次的目标目录和 ok=true;内容变了/没记录则 ok=false(要重新探测)。 +func (o *Organizer) matchResume(f scanner.File) (store.ScanLog, bool) { + if o.resume == nil { + return store.ScanLog{}, false + } + sl, ok := o.resume[f.Path] + if !ok { + return sl, false + } + st, err := os.Stat(f.Path) + if err != nil { + return sl, false + } + if st.Size() != sl.FileSize { + return sl, false + } + // 大文件再核对头尾指纹,确认内容真没变才放心跳过 + if sl.FileSize > digest.FingerprintThreshold && sl.HeadMd5 != "" && sl.TailMd5 != "" { + head, tail, ferr := digest.CalcFileFingerprint(f.Path, st.Size()) + if ferr != nil || head != sl.HeadMd5 || tail != sl.TailMd5 { + return sl, false + } + } + return sl, true +} + +// flushScanLog 把攒下的续传记录一次性写进数据库(中断时尽量少丢已归类的结果)。 +func (o *Organizer) flushScanLog() { + if o.store == nil || len(o.pendingScan) == 0 { + return + } + if err := o.store.SaveScanLogs(o.pendingScan); err != nil { + fmt.Printf("=== 保存续传记录失败: %v\n", err) + } + o.pendingScan = nil +} + // calcFileInfo 读取文件大小并算 MD5(文件的"身份证号")。 // // 大文件算全量 MD5 很费时间,所以先用"指纹"(文件大小 + 头尾各 256KB 的 MD5) @@ -290,6 +381,8 @@ type imageProbeResult struct { psd bool // 是 PSD 设计稿,不做处理原样收走 unsupported bool // 不支持的格式,归入"已忽略" probeErr bool // 体检失败(打不开/读不出),归入"读取异常" + resume bool // 续传命中:上次已归类过,跳过全部体检,直接复用目标目录 + resumeDir string // 续传命中的目标目录 res *probe.ProbeResult // 体检出的尺寸等信息(体检成功时才有) md5Str string // 文件 MD5 headMd5 string // 文件头采样 MD5(大文件指纹) @@ -306,6 +399,13 @@ type imageProbeResult struct { // 只读数据库(查指纹),这是安全的。 func (o *Organizer) probeImage(f scanner.File) imageProbeResult { r := imageProbeResult{} + // 续传:上次已归类过、内容没变 → 跳过全部体检,直接复用上次的目标目录 + if sl, ok := o.matchResume(f); ok { + atomic.AddInt32(&o.resumeSkip, 1) + r.resume = true + r.resumeDir = sl.TargetDir + return r + } // PSD/PSB 设计稿不体检,直接标记"原样收走" if strings.EqualFold(f.Ext, ".psd") || strings.EqualFold(f.Ext, ".psb") { r.psd = true @@ -317,7 +417,9 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult { return r } // 探测尺寸 + probeStart := time.Now() res, err := probe.ProbeImage(f.Path, o.ffprobePath) + atomic.AddInt64(&o.tImageProbe, int64(time.Since(probeStart))) if err != nil { r.probeErr = true return r @@ -328,10 +430,14 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult { // 必须在算 MD5 / 分析明暗之前做——转正会改文件内容,得让 MD5 对得上最终文件。 o.correctImageRotation(f, r.res) // 算 MD5(顺便拿指纹,可能省掉全量计算) + md5Start := time.Now() r.md5Str, r.headMd5, r.tailMd5, r.size = o.calcFileInfo(f) + atomic.AddInt64(&o.tImgMd5, int64(time.Since(md5Start))) // 分析明暗(GIF 不做,动画图逐帧分析没意义) if !strings.EqualFold(f.Ext, ".gif") { + brightStart := time.Now() dark, light, berr := probe.AnalyzeBrightness(f.Path) + atomic.AddInt64(&o.tBright, int64(time.Since(brightStart))) if berr != nil { fmt.Printf("=== 明暗分析失败(按中性mid归类): %s \n", f.Path) } else { @@ -340,17 +446,25 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult { } // 色情识别(GIF 不做;没启用检测器就直接跳过) if o.nsfw != nil && !strings.EqualFold(f.Ext, ".gif") { + nsfwStart := time.Now() score, hit, derr := o.nsfw.Detect(f.Path) + atomic.AddInt64(&o.tNsfw, int64(time.Since(nsfwStart))) if derr != nil { // 检测失败按"未命中"处理,不影响整理流程 fmt.Printf("=== 色情检测失败(按未命中归类): %s, 错误: %v \n", f.Path, derr) } else { r.nsfwScore = score r.nsfw = hit - // 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对 + checked := atomic.AddInt32(&o.nsfwChecked, 1) if hit { + atomic.AddInt32(&o.nsfwHits, 1) + // 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对 fmt.Printf("=== 色情命中: %s, 检测分=%.2f (阈值=%.2f)\n", f.Path, score, o.nsfw.Threshold()) } + // 每固定张数打一行进度,让前端能看到识别在推进(不逐张刷屏) + if checked%nsfwProgressInterval == 0 { + fmt.Printf("=== 色情识别进度: 已检查 %d 张, 命中 %d 张\n", checked, atomic.LoadInt32(&o.nsfwHits)) + } } } return r @@ -399,6 +513,10 @@ func (o *Organizer) ProcessImages(images []scanner.File) { success, fail, ignore, dup := 0, 0, 0, 0 nsfwCount := 0 // 命中的色情图片数 total := len(images) + phaseStart := time.Now() + if o.nsfw != nil { + fmt.Printf("=== 色情识别: 共 %d 张图片待检测(GIF跳过), 每 %d 张报一次进度\n", total, nsfwProgressInterval) + } results := runParallel(o.ctx, images, o.probeImage, func(done int) { o.report(PhaseImages, done, total) }) @@ -410,17 +528,21 @@ func (o *Organizer) ProcessImages(images []scanner.File) { } f := images[i] switch { + case r.resume: + // 续传命中:上次已归类过,直接把文件归回原目标目录(没搬走的这次搬) + o.addToDir(f, r.resumeDir) + success++ case r.psd: // PSD 设计稿:不做处理,原样收进「图片_PSD」 - o.add(f, dirImagePSD) + o.addClassified(f, dirImagePSD, r.size, r.headMd5, r.tailMd5) success++ case r.unsupported: // 不支持的格式:原样收进「图片_已忽略」 - o.add(f, dirImageIgnored) + o.addClassified(f, dirImageIgnored, r.size, r.headMd5, r.tailMd5) ignore++ case r.probeErr: // 体检失败:收进「图片_读取异常」 - o.add(f, dirImageErr) + o.addClassified(f, dirImageErr, r.size, r.headMd5, r.tailMd5) fail++ fmt.Printf("=== 异常图片: %s \n", f.Path) case o.isDuplicate(r.md5Str, f.Path): @@ -432,7 +554,7 @@ func (o *Organizer) ProcessImages(images []scanner.File) { // 被判为色情的改去「图片_敏感_*」目录,明暗子目录照常保留 o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size) if r.nsfw { - o.add(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore)) + o.addClassified(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5) // 攒一条色情识别记录,最后连同"搬去哪了"一起写进数据库 o.pendingNsfw = append(o.pendingNsfw, store.NsfwRecord{ FilePath: f.Path, @@ -442,12 +564,23 @@ func (o *Organizer) ProcessImages(images []scanner.File) { }) nsfwCount++ } else { - o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore)) + o.addClassified(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5) } success++ } } + // 把续传记录落库(中断/出错时尽量少丢已归类的结果) + o.flushScanLog() + if skip := atomic.LoadInt32(&o.resumeSkip); skip > 0 { + fmt.Printf("=== 续传跳过: %d 个文件(上次已归类,直接复用)\n", skip) + } fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup) + fmt.Printf("=== 图片归类耗时: 实际 %.1fs, 其中(并行累计) 探测 %.1fs, MD5 %.1fs, 明暗 %.1fs, 色情 %.1fs\n", + time.Since(phaseStart).Seconds(), + time.Duration(atomic.LoadInt64(&o.tImageProbe)).Seconds(), + time.Duration(atomic.LoadInt64(&o.tImgMd5)).Seconds(), + time.Duration(atomic.LoadInt64(&o.tBright)).Seconds(), + time.Duration(atomic.LoadInt64(&o.tNsfw)).Seconds()) if o.nsfw != nil && nsfwCount > 0 { fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【色情识别】页面查看并人工复核\n", nsfwCount) } @@ -457,18 +590,27 @@ func (o *Organizer) ProcessImages(images []scanner.File) { // mediaProbeResult 是"给一个视频/音频做完体检"的结论(并行阶段产出)。 type mediaProbeResult struct { - res *probe.ProbeResult // 体检结果(ffprobe 读出的信息) - err error // 体检失败的原因 - md5 string // 文件 MD5 - head string // 文件头采样 MD5 - tail string // 文件尾采样 MD5 - size int64 // 文件大小 + res *probe.ProbeResult // 体检结果(ffprobe 读出的信息) + err error // 体检失败的原因 + md5 string // 文件 MD5 + head string // 文件头采样 MD5 + tail string // 文件尾采样 MD5 + size int64 // 文件大小 + resume bool // 续传命中:上次已归类过,跳过全部体检 + resumeDir string // 续传命中的目标目录 } // probeMedia 给一个媒体文件做体检(并行阶段执行):调 ffprobe 读信息 + 算 MD5。 // 不访问任何共享可变状态,多个工人可以同时跑。 func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult { + // 续传:上次已归类过、内容没变 → 跳过 ffprobe/MD5,直接复用上次的目标目录 + if sl, ok := o.matchResume(f); ok { + atomic.AddInt32(&o.resumeSkip, 1) + return mediaProbeResult{resume: true, resumeDir: sl.TargetDir} + } + probeStart := time.Now() res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path) + atomic.AddInt64(&o.tMediaProbe, int64(time.Since(probeStart))) if err != nil { return mediaProbeResult{err: err} } @@ -476,7 +618,9 @@ func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult { if !res.HasVideo && !res.HasAudio { return mediaProbeResult{res: res} } + md5Start := time.Now() md5, head, tail, size := o.calcFileInfo(f) + atomic.AddInt64(&o.tMediaMd5, int64(time.Since(md5Start))) return mediaProbeResult{res: res, md5: md5, head: head, tail: tail, size: size} } @@ -488,6 +632,7 @@ func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult { func (o *Organizer) ProcessMedia(media []scanner.File) { video, audio, fail, ignore, dup := 0, 0, 0, 0, 0 total := len(media) + phaseStart := time.Now() results := runParallel(o.ctx, media, o.probeMedia, func(done int) { o.report(PhaseMedia, done, total) }) @@ -498,17 +643,22 @@ func (o *Organizer) ProcessMedia(media []scanner.File) { break } f := media[i] + // 续传命中:上次已归类过,直接把文件归回原目标目录(没搬走的这次搬) + if r.resume { + o.addToDir(f, r.resumeDir) + continue + } // ---------- 体检失败 ---------- if r.err != nil { // 只有 MIME 明确说是视频/音频的才收进"异常/忽略"目录; // 完全没头绪的文件(MIME 为空或 octet-stream)原地不管,不搬走 if strings.HasPrefix(f.Mime, "video/") { fail++ - o.add(f, dirVideoErr) + o.addClassified(f, dirVideoErr, r.size, r.head, r.tail) fmt.Printf("=== 异常视频: %s \n", f.Path) } else if scanner.IsExplicitMediaMime(f.Mime) { ignore++ - o.add(f, dirVideoIgnored) + o.addClassified(f, dirVideoIgnored, r.size, r.head, r.tail) } else { ignore++ fmt.Printf("=== 无法确认的非媒体文件, 原地忽略: %s \n", f.Path) @@ -527,7 +677,7 @@ func (o *Organizer) ProcessMedia(media []scanner.File) { } video++ o.record(f, "video", r.res, r.md5, r.head, r.tail, r.size) - o.add(f, classify.VideoDir(f.Ext, r.res.Width, r.res.Height)) + o.addClassified(f, classify.VideoDir(f.Ext, r.res.Width, r.res.Height), r.size, r.head, r.tail) case r.res.HasAudio: // 有音频流 → 音频 if o.isDuplicate(r.md5, f.Path) { @@ -538,20 +688,29 @@ func (o *Organizer) ProcessMedia(media []scanner.File) { } audio++ o.record(f, "audio", r.res, r.md5, r.head, r.tail, r.size) - o.add(f, dirAudio) + o.addClassified(f, dirAudio, r.size, r.head, r.tail) default: // 没有视频流也没有音频流 → 假媒体。 // MIME 明确标注是媒体的,收进「已忽略」;否则原地不动。 if scanner.IsExplicitMediaMime(f.Mime) { ignore++ - o.add(f, dirVideoIgnored) + o.addClassified(f, dirVideoIgnored, r.size, r.head, r.tail) } else { ignore++ fmt.Printf("=== 无法确认的非媒体文件, 原地忽略: %s \n", f.Path) } } } + // 把续传记录落库(中断/出错时尽量少丢已归类的结果) + o.flushScanLog() + if skip := atomic.LoadInt32(&o.resumeSkip); skip > 0 { + fmt.Printf("=== 续传跳过: %d 个文件(上次已归类,直接复用)\n", skip) + } fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", video, audio, fail, ignore, dup) + fmt.Printf("=== 媒体归类耗时: 实际 %.1fs, 其中(并行累计) ffprobe %.1fs, MD5 %.1fs\n", + time.Since(phaseStart).Seconds(), + time.Duration(atomic.LoadInt64(&o.tMediaProbe)).Seconds(), + time.Duration(atomic.LoadInt64(&o.tMediaMd5)).Seconds()) } // ============================ 移动(最后一步) ============================ @@ -562,6 +721,7 @@ func (o *Organizer) ProcessMedia(media []scanner.File) { // 不需要记录每个文件搬去哪了——文件就在分类目录里,一眼能看到。 func (o *Organizer) Flush() { o.flushRecords() + o.flushScanLog() // 把 buckets 里所有文件拼成一张"搬运清单" jobs := make([]mover.MoveJob, 0) diff --git a/internal/organize/resume_test.go b/internal/organize/resume_test.go new file mode 100644 index 0000000..914a7bf --- /dev/null +++ b/internal/organize/resume_test.go @@ -0,0 +1,74 @@ +package organize + +import ( + "os" + "path/filepath" + "testing" + + "kenaito-media-picker/internal/digest" + "kenaito-media-picker/internal/scanner" + "kenaito-media-picker/internal/store" +) + +func TestResumeMatchSize(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.jpg") + os.WriteFile(p, []byte("hello world hello world"), 0644) + st, _ := os.Stat(p) + + org := New(filepath.Join(dir, "out"), "", "", nil, nil) + org.resume = map[string]store.ScanLog{ + p: {SourcePath: p, TargetDir: filepath.Join(dir, "out", "图片_测试"), FileSize: st.Size()}, + } + f := scanner.File{Path: p, Name: "a.jpg", Ext: ".jpg", Mime: "image/jpeg"} + + sl, ok := org.matchResume(f) + if !ok || sl.TargetDir != filepath.Join(dir, "out", "图片_测试") { + t.Fatalf("内容未变应命中续传, ok=%v", ok) + } + + // 内容变大 → 大小不同 → 不命中 + os.WriteFile(p, []byte("hello world hello world extra bytes"), 0644) + if _, ok := org.matchResume(f); ok { + t.Fatal("内容变了(大小不同)应不命中续传") + } + + // 没记录 → 不命中 + org.resume = nil + if _, ok := org.matchResume(f); ok { + t.Fatal("无续传记录应不命中") + } +} + +func TestResumeMatchFingerprint(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "big.bin") + data := make([]byte, 2<<20) + for i := range data { + data[i] = byte(i) + } + os.WriteFile(p, data, 0644) + st, _ := os.Stat(p) + head, tail, err := digest.CalcFileFingerprint(p, st.Size()) + if err != nil { + t.Fatalf("fingerprint: %v", err) + } + + org := New(filepath.Join(dir, "out"), "", "", nil, nil) + org.resume = map[string]store.ScanLog{ + p: {SourcePath: p, TargetDir: filepath.Join(dir, "out", "视频_测试"), FileSize: st.Size(), HeadMd5: head, TailMd5: tail}, + } + f := scanner.File{Path: p, Name: "big.bin", Ext: ".bin", Mime: "video/mp4"} + + if _, ok := org.matchResume(f); !ok { + t.Fatal("指纹一致应命中续传") + } + + // 改文件头里一个字节、大小不变 → 指纹不同 → 不命中 + mod := append([]byte(nil), data...) + mod[1024] ^= 0xFF + os.WriteFile(p, mod, 0644) + if _, ok := org.matchResume(f); ok { + t.Fatal("内容变了(大小相同)应不命中续传") + } +} \ No newline at end of file diff --git a/internal/probe/brightness.go b/internal/probe/brightness.go index 34994d3..b9419d1 100644 --- a/internal/probe/brightness.go +++ b/internal/probe/brightness.go @@ -152,6 +152,23 @@ func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) offset++ } } + case *image.YCbCr: + // JPEG 解码出来是 YCbCr:Y 平面就是亮度,直接翻数组。 + // 这是最常见的照片格式,走这分支能避开 default 里逐像素 img.At().RGBA() 的慢路径。 + pix := m.Y + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + offset := y*m.YStride + bounds.Min.X + for x := bounds.Min.X; x < bounds.Max.X; x++ { + v := float64(pix[offset]) + if v < 80 { + darkCount++ + } else if v > 175 { + lightCount++ + } + sampleCount++ + offset++ + } + } default: // 其他格式:用通用的读法 for y := bounds.Min.Y; y < bounds.Max.Y; y++ { diff --git a/internal/probe/ffprobe.go b/internal/probe/ffprobe.go index 55baa2e..6783843 100644 --- a/internal/probe/ffprobe.go +++ b/internal/probe/ffprobe.go @@ -3,15 +3,24 @@ package probe import ( "fmt" "os" + "os/exec" ) // FFprobeExeName 释放出来的解码器文件名。程序启动时扫到它要跳过, // 免得把自己人(ffprobe)当成媒体文件收走了。 +// 只在 Windows(有内嵌 ffprobe.exe)时才会真正释放出来。 const FFprobeExeName = "ffprobe.exe" -// FFprobePath 返回当前目录下 ffprobe 的完整路径。 +// systemFFprobe 回退用的系统 ffprobe 路径(非 Windows 平台、没有内嵌时启用)。 +var systemFFprobe string + +// FFprobePath 返回当前要用的 ffprobe 完整路径: +// 有内嵌就先释放到当前目录用内嵌的;没有内嵌则用系统 PATH 里的那个。 func FFprobePath() string { - return "." + string(os.PathSeparator) + FFprobeExeName + if len(embeddedFFprobe) > 0 { + return "." + string(os.PathSeparator) + FFprobeExeName + } + return systemFFprobe } // HasEmbeddedFFprobe 当前编译出的程序里有没有内嵌 ffprobe。 @@ -20,15 +29,23 @@ func HasEmbeddedFFprobe() bool { return len(embeddedFFprobe) > 0 } -// ReleaseFFprobe 把内嵌的 ffprobe 释放(写)到当前目录,才能当程序调用它。 +// ReleaseFFprobe 准备出一个可用的 ffprobe: +// - 有内嵌 → 释放(写)到当前目录,才能当程序调用它; +// - 没有内嵌(如 Linux 构建)→ 找系统 PATH 里的 ffprobe(如 /usr/bin/ffprobe)。 +// +// 两者都拿不到才报错:调用方会跳过视频/音频整理,图片照常处理。 func ReleaseFFprobe() error { - if len(embeddedFFprobe) == 0 { - return fmt.Errorf("当前系统没有内嵌的ffprobe") + if len(embeddedFFprobe) > 0 { + return os.WriteFile(FFprobeExeName, embeddedFFprobe, 0777) } - return os.WriteFile(FFprobeExeName, embeddedFFprobe, 0777) + if p, err := exec.LookPath("ffprobe"); err == nil { + systemFFprobe = p + return nil + } + return fmt.Errorf("没有内嵌 ffprobe,系统 PATH 里也找不到 ffprobe(Linux 请先安装 ffmpeg)") } // RemoveFFprobe 整理完把临时释放的 ffprobe 删掉,不留在目录里。 func RemoveFFprobe() { _ = os.Remove(FFprobeExeName) -} +} \ No newline at end of file diff --git a/internal/probe/ffprobe_embed_other.go b/internal/probe/ffprobe_embed_other.go new file mode 100644 index 0000000..2d6a384 --- /dev/null +++ b/internal/probe/ffprobe_embed_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package probe + +// 非 Windows 平台不在二进制里内嵌 ffprobe(仓库只带了 Windows 版)。 +// 运行时回退用系统 PATH 里的 ffprobe:如 CentOS 上 yum install ffmpeg 后 +// 就有 /usr/bin/ffprobe。找不到系统 ffprobe 时,视频/音频整理自动跳过,图片照常。 +var embeddedFFprobe []byte \ No newline at end of file diff --git a/internal/probe/ffprobe_embed.go b/internal/probe/ffprobe_embed_windows.go similarity index 77% rename from internal/probe/ffprobe_embed.go rename to internal/probe/ffprobe_embed_windows.go index 466df13..dfa7468 100644 --- a/internal/probe/ffprobe_embed.go +++ b/internal/probe/ffprobe_embed_windows.go @@ -1,6 +1,8 @@ +//go:build windows + package probe import _ "embed" //go:embed bin/ffprobe.exe -var embeddedFFprobe []byte // Windows 版 ffprobe(未入库,见 .gitignore) +var embeddedFFprobe []byte // Windows 版 ffprobe(未入库,见 .gitignore) \ No newline at end of file diff --git a/internal/service/scan.go b/internal/service/scan.go index 7d40473..ecff299 100644 --- a/internal/service/scan.go +++ b/internal/service/scan.go @@ -69,9 +69,25 @@ type ScanStatus struct { // ============================ 应用级常量 ============================ -// 应用级配置键。目前只存了一个:上次使用的存放目录,方便下次打开页面自动回填。 +// 应用级配置键。目前只存了两个:上次使用的存放目录、色情检测服务是否真的起来了。 const ConfigKeyDestDir = "scan.destDir" +// nsfwDetectorReadyKey 记录最近一次扫描时色情检测服务是否真的起来了(python+nudenet 可用)。 +// 配置开了但检测器没起来(没装 python/模型下载失败等)也能区分出来,前端据此提示。 +const nsfwDetectorReadyKey = "nsfw.detectorReady" + +// setNsfwDetectorReady 把"色情检测服务是否真的起来了"记进数据库(AppConfig)。 +func (s *ScanService) setNsfwDetectorReady(ready bool) { + if s.store == nil { + return + } + v := "0" + if ready { + v = "1" + } + _ = s.store.SaveConfig(nsfwDetectorReadyKey, v) +} + // DefaultPickerDirName 用户没指定存放目录时,默认在扫描目录下新建这个文件夹装整理好的文件。 const DefaultPickerDirName = "KMP已整理" @@ -469,10 +485,11 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro res.Filter() // ---------- 第三步:准备解码器 ---------- - // 读取视频/音频信息需要 ffprobe。程序里内嵌了一份,用之前先释放到当前目录。 - // 释放失败就跳过视频/音频整理,图片照常处理。 + // 读取视频/音频信息需要 ffprobe。Windows 构建内嵌了一份,先释放到当前目录; + // Linux 构建没有内嵌,自动回退用系统 PATH 里的 ffprobe(如 /usr/bin/ffprobe)。 + // 两者都没有就跳过视频/音频整理,图片照常处理。 ffprobePath := "" - if (len(res.Images) > 0 || len(res.Media) > 0) && probe.HasEmbeddedFFprobe() { + if len(res.Images) > 0 || len(res.Media) > 0 { if err := probe.ReleaseFFprobe(); err != nil { fmt.Printf("=== 释放解码器失败, 视频/音频整理将跳过(图片照常处理): %v\n", err) } else { @@ -498,12 +515,14 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro // 用本机 python + NudeNet 起一个本地检测服务。没装 python/nudenet 时跳过, // 不影响图片整理——检测结果只决定走普通目录还是「图片_敏感」目录。 nsfwDet := (*nsfw.Detector)(nil) + s.setNsfwDetectorReady(false) if cfg := config.GetAppConfig(); cfg.NSFWEnabled { if d, err := nsfw.Start(cfg.NSFWPython, cfg.NSFWThreshold); err != nil { fmt.Printf("=== 色情识别未启用(图片照常处理): %v\n", err) } else { nsfwDet = d defer nsfwDet.Stop() + s.setNsfwDetectorReady(true) fmt.Println("=== 色情识别已启用(NudeNet)") // 清掉上一次的色情识别记录,保证「色情识别」页面显示的是本次扫描的结果 if s.store != nil { diff --git a/internal/store/nsfw_batch_test.go b/internal/store/nsfw_batch_test.go new file mode 100644 index 0000000..bdf2a68 --- /dev/null +++ b/internal/store/nsfw_batch_test.go @@ -0,0 +1,44 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSaveNsfwBatchLarge(t *testing.T) { + db := filepath.Join(t.TempDir(), "test.db") + s, err := Open(db) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer func() { + s.Close() + os.Remove(db) + }() + + // 3000 条,远超 SQLite 999 变量上限(旧代码会报 too many SQL variables) + records := make([]*NsfwRecord, 0, 3000) + for i := 0; i < 3000; i++ { + records = append(records, &NsfwRecord{ + FilePath: fmt.Sprintf("/path/%d.jpg", i), + FileName: fmt.Sprintf("%d.jpg", i), + Score: 0.5, + Threshold: 0.2, + MovedPath: fmt.Sprintf("/moved/%d.jpg", i), + CreatedAt: time.Now(), + }) + } + if err := s.SaveNsfwBatch(records); err != nil { + t.Fatalf("SaveNsfwBatch: %v", err) + } + list, err := s.ListNsfw() + if err != nil { + t.Fatalf("ListNsfw: %v", err) + } + if len(list) != 3000 { + t.Fatalf("got %d records, want 3000", len(list)) + } +} \ No newline at end of file diff --git a/internal/store/scanlog_test.go b/internal/store/scanlog_test.go new file mode 100644 index 0000000..9492e54 --- /dev/null +++ b/internal/store/scanlog_test.go @@ -0,0 +1,40 @@ +package store + +import ( + "path/filepath" + "testing" +) + +func TestScanLogRoundTrip(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + + if err := s.SaveScanLogs([]ScanLog{ + {SourcePath: "/a.jpg", TargetDir: "/out/图片_A", FileSize: 100}, + {SourcePath: "/b.mp4", TargetDir: "/out/视频_B", FileSize: 200}, + }); err != nil { + t.Fatalf("SaveScanLogs: %v", err) + } + + m, err := s.LoadScanLog() + if err != nil { + t.Fatalf("LoadScanLog: %v", err) + } + if len(m) != 2 || m["/a.jpg"].TargetDir != "/out/图片_A" { + t.Fatalf("加载结果不对: %v", m) + } + + // 同路径 upsert:覆盖旧目标目录与大小,不新增行 + if err := s.SaveScanLogs([]ScanLog{ + {SourcePath: "/a.jpg", TargetDir: "/out/图片_A2", FileSize: 99}, + }); err != nil { + t.Fatalf("SaveScanLogs upsert: %v", err) + } + m2, _ := s.LoadScanLog() + if len(m2) != 2 || m2["/a.jpg"].TargetDir != "/out/图片_A2" || m2["/a.jpg"].FileSize != 99 { + t.Fatalf("upsert 结果不对: %v", m2) + } +} \ No newline at end of file diff --git a/internal/store/store.go b/internal/store/store.go index 6e2459a..a3bcb88 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -75,6 +75,20 @@ type NsfwRecord struct { CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 检测时间 } +// ScanLog 一条"续传"记录:某个源文件上次被归类到了哪个目录。 +// 用途:扫描中断/出错后重跑时,已经归类过的文件直接复用上次的目标目录, +// 不再重新探测尺寸 / 算 MD5 / 分析明暗 / 色情识别,省下几小时的重扫时间。 +// 大文件记录头尾指纹,重扫时核对内容没变才放心跳过。 +type ScanLog struct { + Id int64 `xorm:"pk autoincr 'id'" json:"id"` + SourcePath string `xorm:"varchar(1024) unique 'source_path'" json:"sourcePath"` // 源文件路径(唯一) + TargetDir string `xorm:"text 'target_dir'" json:"targetDir"` // 归类结果:目标目录 + FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(校验内容是否变化) + HeadMd5 string `xorm:"char(32) 'head_md5'" json:"headMd5"` // 文件头 256KB MD5(大文件指纹) + TailMd5 string `xorm:"char(32) 'tail_md5'" json:"tailMd5"` // 文件尾 256KB MD5(大文件指纹) + UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近归类时间 +} + // Open 打开(或第一次时创建)数据库,并保证表结构齐全。 func Open(dbPath string) (*Store, error) { e, err := xorm.NewEngine("sqlite3", dbPath) @@ -82,10 +96,18 @@ func Open(dbPath string) (*Store, error) { return nil, err } e.ShowSQL(false) - // SQLite 同一时刻只允许一个写连接,这里限制成 1 个,避免并发写冲突 - e.SetMaxOpenConns(1) - // 自动创建/补全三张表(媒体记录表、配置表、色情识别记录表) - if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord)); err != nil { + // SQLite 同一时刻只允许一个写连接。这里把读连接放开几路: + // 扫描探测阶段是并发读(查指纹),被 1 个连接串行化会拖慢并行; + // 写仍走单事务(批量入库),由 SQLite 自己的锁机制串行,不会冲突。 + e.SetMaxOpenConns(8) + // 给数据库加个忙等待,万一遇到并发写也不至于立刻报 SQLITE_BUSY + e.SetMaxIdleConns(8) + if _, err := e.Exec("PRAGMA busy_timeout = 10000"); err != nil { + _ = e.Close() + return nil, err + } + // 自动创建/补全四张表(媒体记录表、配置表、色情识别记录表、续传表) + if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord), new(ScanLog)); err != nil { _ = e.Close() return nil, err } @@ -138,12 +160,29 @@ func (s *Store) GetConfig(key string) string { // ============================ 色情识别记录 ============================ // SaveNsfwBatch 批量保存色情识别记录(一次扫描攒一批,最后统一写入)。 +// 一次扫描可能命中几千张,一条 INSERT 塞不下(SQLite 单条语句最多 999 个变量), +// 所以按 chunkSize 一组一组插,外面套一个事务保证要么全成功要么全回滚。 func (s *Store) SaveNsfwBatch(records []*NsfwRecord) error { if s == nil || s.engine == nil || len(records) == 0 { return nil } - _, err := s.engine.Insert(records) - return err + const chunkSize = 100 + sess := s.engine.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + for i := 0; i < len(records); i += chunkSize { + end := i + chunkSize + if end > len(records) { + end = len(records) + } + if _, err := sess.Insert(records[i:end]); err != nil { + _ = sess.Rollback() + return err + } + } + return sess.Commit() } // ClearNsfw 清空色情识别记录。每次开启色情识别的扫描开始时调用, @@ -156,6 +195,48 @@ func (s *Store) ClearNsfw() error { return err } +// ============================ 续传表 ============================ + +// LoadScanLog 把全部续传记录读出来,按源路径建索引,供扫描时快速判断"这个文件上次归到哪了"。 +func (s *Store) LoadScanLog() (map[string]ScanLog, error) { + m := make(map[string]ScanLog) + if s == nil || s.engine == nil { + return m, nil + } + list := make([]ScanLog, 0) + if err := s.engine.Find(&list); err != nil { + return nil, err + } + for _, e := range list { + m[e.SourcePath] = e + } + return m, nil +} + +// SaveScanLogs 批量保存续传记录(按源路径 upsert)。攒一批一起写,少开事务。 +// 单行删除 + 单行插入,没有 999 变量的限制,一批全在一个事务里。 +func (s *Store) SaveScanLogs(entries []ScanLog) error { + if s == nil || s.engine == nil || len(entries) == 0 { + return nil + } + sess := s.engine.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + for _, e := range entries { + if _, err := sess.Where("source_path = ?", e.SourcePath).Delete(new(ScanLog)); err != nil { + _ = sess.Rollback() + return err + } + if _, err := sess.Insert(&e); err != nil { + _ = sess.Rollback() + return err + } + } + return sess.Commit() +} + // ListNsfw 查全部色情识别记录,按检测分从高到低排(方便先看最"实锤"的)。 func (s *Store) ListNsfw() ([]NsfwRecord, error) { if s == nil || s.engine == nil {