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
+44
View File
@@ -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))
}
}
+40
View File
@@ -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)
}
}
+87 -6
View File
@@ -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 {