From 20df2ff06edf1b7014ca6b4b145c52286669f9f5 Mon Sep 17 00:00:00 2001 From: eaiadmin Date: Mon, 14 Sep 2026 16:33:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SQLite=20=E5=AE=9A=E6=9C=9F=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E5=86=85=E7=BD=AE=E8=BF=9B=E4=BA=8C=E8=BF=9B=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite 是单文件、没有外部主从,坏一份就是全丢,而 data/ 一直被 gitignore、 此前没有任何备份。备份做进二进制,不依赖外部 cron / systemd timer。 - 快照用 VACUUM INTO,不用 cp 主库:服务边跑边写时 cp 可能拷到写了一半的页 - 启动时补一次,之后每小时醒一次看是否到期;默认间隔 24h、保留最近 7 份 - 产出自检 SQLite 文件头,不合格即删并报错,不留「看着像备份的废物」 - 新增 -backup 子命令,供升级 / 迁移 / 交付前手工备一份 - 落 data/backups/ —— systemd 单元是 ProtectSystem=strict,只放开了 data/ 可写 - 边界写进文档:只防误删 / 误改 / 写坏,不防整盘损坏,跨机保存仍需人工 配套: - deploy/DELIVERY.md 新增第 7 节「数据备份与恢复」+ 红线条目 - clonezilla-cleanup.sh 提示克隆前清空 data/backups/,避免原型机测试数据被带走 - deploy/eaisalestrain.env 补 4 个备份环境变量 - PROJECT_STATE.md 记 D24 另:清理 worker_task 中 17 条没聊过天的历史空任务(id 1-11、13-18), 删前先备份、清单经确认,删除程序带「命中数与白名单不一致即中止」保险。 该项为一次性数据操作,不产生代码改动。 Co-Authored-By: Claude Code --- eai_agentplatform/PROJECT_STATE.md | 1 + .../backend-go/cmd/server/main.go | 24 ++ .../backend-go/deploy/DELIVERY.md | 45 ++++ .../backend-go/deploy/clonezilla-cleanup.sh | 2 + .../backend-go/deploy/eaisalestrain.env | 7 + .../backend-go/internal/config/config.go | 29 +++ .../backend-go/internal/store/backup.go | 212 +++++++++++++++ .../backend-go/internal/store/backup_test.go | 241 ++++++++++++++++++ 8 files changed, 561 insertions(+) create mode 100644 eai_agentplatform/backend-go/internal/store/backup.go create mode 100644 eai_agentplatform/backend-go/internal/store/backup_test.go diff --git a/eai_agentplatform/PROJECT_STATE.md b/eai_agentplatform/PROJECT_STATE.md index 95a9b86..d87d3b8 100644 --- a/eai_agentplatform/PROJECT_STATE.md +++ b/eai_agentplatform/PROJECT_STATE.md @@ -65,6 +65,7 @@ | D21 | 前端建立「冻结归档」机制:停用代码不删除,集中到 `frontend/src/_frozen/`(统一 `.frozen` 后缀 + `FROZEN.md` 登记表,逐条记录原位置/冻结原因/外部依赖/**解冻条件**/解冻步骤)。首批冻结:工作区胶囊条(外壳重构后成了冗余的第二套导航)及外壳重构遗留的四个孤儿文件。**D19 的站内通知只冻前端 UI** —— 后端四个接口保留,`store`/`api` 层保留(`BusinessAppPage` 仍在用),解冻为纯前端工作量 | 已定(见 `frontend/src/_frozen/FROZEN.md`) | | D22 | 任务记录**延迟到发出第一条消息时创建**(对齐 WorkBuddy:新建任务只是导航动作,不落库)。「新建任务」只做「回到 /home 开场画布 + 清空当前任务」,任务标题由第一句话决定、专员取输入框 `+` 里先选的那个(`pendingSpecialistKey` 暂存,建任务时作为 `specialist_key` 一并提交,后端已支持)。**不允许存在没聊过天的空任务** —— 原实现点一次落一条 `新任务`,是「最近任务」堆满空行的根因 | 已定(V1.8 落地) | | D23 | 侧边栏「最近任务」的行菜单(对齐 ChatGPT 悬停 `⋯` + WorkBuddy 任务卡「更多」):**重命名**(就地编辑,回车提交/Esc 取消)、**置顶**(新增 `worker_task.pinned`,排序 `pinned DESC, updated_at DESC`)、**删除**(新增 `DELETE /api/worker/my-tasks/:id`,硬删、无回收站,确认框写明「无法恢复」)。对照后**不做**:分享任务(纯内网无意义)、打开文件夹(Web 无此能力)、保存到工作空间(项目无 workspace 概念)、归档(没有「已归档」入口可找回,等于软删陷阱)。`el-dropdown` 加 `:persistent="false"` —— 默认每行留一份隐藏菜单在 DOM 里,50 条任务就是 50 份 | 已定 | +| D24 | SQLite **定期备份内置进二进制**(不用外部 cron/systemd timer):启动时补一次 + 每小时查是否到期,默认间隔 24h、保留最近 7 份,落在 `data/backups/`(systemd 是 `ProtectSystem=strict`,只放开了 `data/` 可写)。快照用 `VACUUM INTO` 而非 `cp` 主库 —— 边服务边写时 `cp` 可能拷到写了一半的页。产出自检 SQLite 文件头,不合格即删。另提供 `-backup` 子命令供升级/迁移前手工备一份。**边界**:只防误删/误改/写坏,不防整盘损坏,跨机保存仍需人工 | 已定(见 `deploy/DELIVERY.md` 第 7 节) | ## 4. 文档索引 diff --git a/eai_agentplatform/backend-go/cmd/server/main.go b/eai_agentplatform/backend-go/cmd/server/main.go index 389a5ca..598595b 100644 --- a/eai_agentplatform/backend-go/cmd/server/main.go +++ b/eai_agentplatform/backend-go/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "log" "os" + "time" "github.com/gin-gonic/gin" @@ -20,6 +21,9 @@ func main() { case "-reset-admin": resetAdmin(os.Args[2:]) return + case "-backup": + backupOnce() + return } } @@ -32,6 +36,12 @@ func main() { log.Fatalf("种子数据初始化失败: %v", err) } + // 定期备份:SQLite 是单文件且无外部副本,坏一份就是全丢。 + // 失败只在日志里报,绝不因此拦下服务。 + if cfg.BackupEnabled { + store.StartBackupLoop(store.DB, cfg.DBPath, cfg.BackupDir, cfg.BackupKeep, cfg.BackupIntervalHours) + } + r := gin.Default() api.RegisterRoutes(r, cfg) @@ -42,6 +52,20 @@ func main() { } } +// backupOnce 手工备份一次后退出:eai_agentplatform-server -backup +// 用于升级/迁移/交付前留一份手边的副本,比等定期那份更让人放心。 +func backupOnce() { + cfg := config.Load() + if _, err := store.Init(cfg.DBPath); err != nil { + log.Fatalf("数据库初始化失败: %v", err) + } + path, err := store.Backup(store.DB, cfg.BackupDir, cfg.BackupKeep, time.Now()) + if err != nil { + log.Fatalf("备份失败: %v", err) + } + log.Printf("[OK] 已备份到 %s", path) +} + // resetAdmin 交付前重置管理员密码:eai_agentplatform-server -reset-admin <新密码> func resetAdmin(args []string) { if len(args) < 1 || args[0] == "" { diff --git a/eai_agentplatform/backend-go/deploy/DELIVERY.md b/eai_agentplatform/backend-go/deploy/DELIVERY.md index abef6db..53bda47 100644 --- a/eai_agentplatform/backend-go/deploy/DELIVERY.md +++ b/eai_agentplatform/backend-go/deploy/DELIVERY.md @@ -29,6 +29,7 @@ ├── .env # JWT 密钥等(交付前生成,勿提交源码库) ├── data/ │ ├── eai_agentplatform.db # SQLite(首启自动建) +│ ├── backups/ # 定期备份(服务自动维护,见第 7 节) │ └── kb_data/ # 素材 + 提取缓存 ├── knowledge_source/ # 知识源 Markdown └── (前端静态资源由 Nginx 托管,见 ../docs/deploy.md) @@ -131,3 +132,47 @@ SQL - [ ] 管理员密码非 `admin123` - [ ] `.bash_history` 已清空、无 `.git` - [ ] 测试账号(zhangsan 等)、演示素材、演示考试记录已删除 +- [ ] `data/backups/` 里没有**原型机自己的**数据备份(见第 7 节末) + +--- + +## 7. 数据备份与恢复 + +SQLite 是单文件、没有主从副本,文件坏一份就是全丢,所以服务自带定期备份,默认开着。 + +**怎么跑**:进程启动时先检查一次(距上次备份够久就补一份),之后每小时醒一次看是否到期。 +不需要外部 cron / systemd timer,备份逻辑在二进制里。间隔和保留份数由 `.env` 控制: + +``` +BACKUP_ENABLED=true # 关掉就完全不备 +BACKUP_DIR=data/backups # 必须在 data/ 下(ProtectSystem=strict 只放开这里) +BACKUP_KEEP=7 # 保留最近几份,更旧的自动删 +BACKUP_INTERVAL_HOURS=24 # 间隔 +``` + +**备份文件长这样**:`data/backups/eai_agentplatform-20260914-162401.db`。 +用 `VACUUM INTO` 产出,不是 `cp` 主库 —— 服务边跑边写时 `cp` 可能拷到写了一半的页; +`VACUUM INTO` 出来的是已压实、内部一致的完整副本,且不用停服。产出的文件会校验 SQLite 文件头, +不是真库就删掉并报错,不会留下「看着像备份的废物」。 + +**手工备一份**(升级、迁移、动数据之前留个手边的副本): + +```bash +sudo -u eai_agentplatform /opt/eai_agentplatform/eai_agentplatform-server -backup +``` + +**恢复**(会覆盖当前数据,先停服): + +```bash +sudo systemctl stop eai_agentplatform +sudo -u eai_agentplatform cp data/backups/eai_agentplatform-20260914-162401.db data/eai_agentplatform.db +sudo systemctl start eai_agentplatform +``` + +> 备份只在**本机 data/ 目录**里,防的是误删/误改/写坏,防不了整盘损坏。 +> 要防整盘,得把 `data/backups/` 定期拷到机器之外(U 盘 / 内网文件服务器)。 + +**克隆前注意**:原型机的 `data/backups/` 里会有原型机自己的历史数据(测试账号、演示素材), +整盘克隆会原样带到客户机器上。清理时一并删掉该目录内容,让客户机器从干净状态开始。 + +恢复之后想核对内容,直接看文件时间戳和大小即可 —— 文件名里的时间就是备份时刻。 diff --git a/eai_agentplatform/backend-go/deploy/clonezilla-cleanup.sh b/eai_agentplatform/backend-go/deploy/clonezilla-cleanup.sh index fb51c5b..981b845 100644 --- a/eai_agentplatform/backend-go/deploy/clonezilla-cleanup.sh +++ b/eai_agentplatform/backend-go/deploy/clonezilla-cleanup.sh @@ -43,6 +43,8 @@ done step "3. 清理测试/演示数据(保留生产库结构,清空内容)" echo " - 提示:测试用户 zhangsan / 演示素材 / 演示考试记录等由管理员在 Web 界面手动清理," echo " 或在克隆前用 sqlite3 执行(见 DELIVERY.md 第 4 节)。脚本不自动清库,避免误删正式数据。" +echo " - 别漏了 $APP_DIR/data/backups/ :里面是原型机自己的历史快照,含同一批测试数据," +echo " 整盘克隆会一起带到客户机器上。确认无用后手工清空该目录(见 DELIVERY.md 第 7 节)。" step "4. 清空日志与 shell 历史" for f in "$APP_DIR/logs" "$APP_DIR"/*.log /var/log/eai_agentplatform*.log; do diff --git a/eai_agentplatform/backend-go/deploy/eaisalestrain.env b/eai_agentplatform/backend-go/deploy/eaisalestrain.env index 7681735..f365e12 100644 --- a/eai_agentplatform/backend-go/deploy/eaisalestrain.env +++ b/eai_agentplatform/backend-go/deploy/eaisalestrain.env @@ -21,6 +21,13 @@ EMBED_MODEL=bge-m3 KB_DATA_DIR=data/kb_data KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/knowledge_source +# 定期备份(默认开:启动时补一次,之后每 BACKUP_INTERVAL_HOURS 小时检查一次,保留最近 BACKUP_KEEP 份) +# 备份目录必须留在 data/ 下 —— systemd 单元是 ProtectSystem=strict,只放开了 data/ 可写 +BACKUP_ENABLED=true +BACKUP_DIR=data/backups +BACKUP_KEEP=7 +BACKUP_INTERVAL_HOURS=24 + # 上传大小限制(字节) FILE_MAX_SIZE_DOC=209715200 FILE_MAX_SIZE_VIDEO=2147483648 diff --git a/eai_agentplatform/backend-go/internal/config/config.go b/eai_agentplatform/backend-go/internal/config/config.go index 9669103..058be85 100644 --- a/eai_agentplatform/backend-go/internal/config/config.go +++ b/eai_agentplatform/backend-go/internal/config/config.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strconv" + "strings" ) // Config 全局配置,从环境变量读取(带默认值)。 @@ -25,6 +26,13 @@ type Config struct { KBDataDir string KnowledgeSourceDir string + // 定期备份。SQLite 是单文件、又没有任何外部主从,坏一份就是全丢, + // 所以默认开着:启动补一次,之后每 BackupIntervalHours 小时一次,保留最近 N 份。 + BackupEnabled bool + BackupDir string + BackupKeep int + BackupIntervalHours int + FileMaxDoc int64 FileMaxVideo int64 ChunkThreshold int64 @@ -61,6 +69,13 @@ func Load() *Config { KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")), KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")), + // 备份目录必须落在 data/ 下:systemd 单元是 ProtectSystem=strict, + // 只有 data/ 可写(ReadWritePaths),换到别处会静默失败。 + BackupEnabled: getenvBool("BACKUP_ENABLED", true), + BackupDir: getenv("BACKUP_DIR", filepath.Join(baseDir, "data", "backups")), + BackupKeep: getenvInt("BACKUP_KEEP", 7), + BackupIntervalHours: getenvInt("BACKUP_INTERVAL_HOURS", 24), + FileMaxDoc: getenvInt64("FILE_MAX_SIZE_DOC", 209715200), FileMaxVideo: getenvInt64("FILE_MAX_SIZE_VIDEO", 2147483648), ChunkThreshold: getenvInt64("CHUNK_THRESHOLD", 104857600), @@ -94,6 +109,20 @@ func getenv(key, def string) string { return def } +// getenvBool 只认显式的假值,其余(含没设)都算真 —— 默认开着的东西, +// 不该因为写了个看不懂的值就悄悄关掉。 +func getenvBool(key string, def bool) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + switch v { + case "": + return def + case "0", "false", "no", "off": + return false + default: + return true + } +} + func getenvInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { diff --git a/eai_agentplatform/backend-go/internal/store/backup.go b/eai_agentplatform/backend-go/internal/store/backup.go new file mode 100644 index 0000000..c344613 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/backup.go @@ -0,0 +1,212 @@ +package store + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gorm.io/gorm" +) + +// 备份文件名:eai_agentplatform-20260914-163000.db。 +// 前缀 + 时间戳 + 后缀既是排序依据,也是「这个文件是我们产的」的判据 —— +// 清理只会删认得出的文件,不碰目录里别的东西。 +const ( + backupPrefix = "eai_agentplatform-" + backupSuffix = ".db" + backupLayout = "20060102-150405" +) + +// Backup 把当前库快照到 dir 下,返回新文件路径;随后按 keep 删掉最旧的几个。 +// +// 用 SQLite 的 VACUUM INTO,而不是直接复制库文件:库是边服务边写的, +// cp 可能拷到写了一半的页;VACUUM INTO 产出的是一份已压实、内部一致的完整副本, +// 而且不需要停服。 +func Backup(db *gorm.DB, dir string, keep int, now time.Time) (string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("创建备份目录失败: %w", err) + } + + dest := filepath.Join(dir, backupPrefix+now.Format(backupLayout)+backupSuffix) + // 同一秒内被调两次(启动补备 + 手工备份撞一起)时,VACUUM INTO 会因目标已存在 + // 而报错。加个计数后缀让第二次也能落盘,而不是白白失败一次。 + for i := 2; ; i++ { + if _, err := os.Stat(dest); os.IsNotExist(err) { + break + } + dest = filepath.Join(dir, fmt.Sprintf("%s%s-%d%s", backupPrefix, now.Format(backupLayout), i, backupSuffix)) + } + + // VACUUM INTO 的目标是表达式,绑定参数比手工拼字符串安全(路径里可能有引号)。 + if err := db.Exec("VACUUM INTO ?", dest).Error; err != nil { + return "", fmt.Errorf("备份失败: %w", err) + } + + // 产出的东西得能打开才算备份成功,否则留下的是一份看着像备份的废物。 + if err := verifyBackup(dest); err != nil { + os.Remove(dest) + return "", err + } + + if removed, err := pruneBackups(dir, keep, dest); err != nil { + // 清理失败不该让备份本身算失败:备份已经落盘了,下次再清。 + log.Printf("[backup] 清理旧备份失败: %v", err) + } else if removed > 0 { + log.Printf("[backup] 已清理 %d 份旧备份(保留最近 %d 份)", removed, keep) + } + return dest, nil +} + +// verifyBackup 打开刚产出的文件,确认它确实是个能读的 SQLite 库。 +func verifyBackup(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("备份文件不存在: %w", err) + } + if info.Size() == 0 { + return fmt.Errorf("备份文件是空的: %s", path) + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("备份文件打不开: %w", err) + } + defer f.Close() + + // SQLite 文件头 16 字节魔数。比开库跑查询轻,够挡住「拷了个空壳」这类失败。 + header := make([]byte, 16) + if _, err := f.Read(header); err != nil { + return fmt.Errorf("备份文件读不出文件头: %w", err) + } + if string(header) != "SQLite format 3\x00" { + return fmt.Errorf("备份文件不是 SQLite 库: %s", path) + } + return nil +} + +// pruneBackups 只保留最近 keep 份,返回删掉的数量。keep <= 0 表示不清理。 +// 文件名是时间戳,所以按名字倒序就是按时间倒序。 +func pruneBackups(dir string, keep int, justCreated string) (int, error) { + if keep <= 0 { + return 0, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), backupPrefix) || !strings.HasSuffix(e.Name(), backupSuffix) { + continue + } + names = append(names, e.Name()) + } + sort.Sort(sort.Reverse(sort.StringSlice(names))) + + removed := 0 + for i, name := range names { + if i < keep { + continue + } + path := filepath.Join(dir, name) + // 兜底:绝不删刚产出的那份,哪怕因为同名后缀把排序搞乱了。 + if path == justCreated { + continue + } + if err := os.Remove(path); err != nil { + return removed, err + } + removed++ + } + return removed, nil +} + +// BackupIfDue 距上一份备份不足 interval 就跳过,返回是否真的备了。 +// +// 启动时补备走这里而不是「每次启动都备」:进程反复重启(crashloop)时, +// 每次都产一份新备份会把保留窗口撑爆,反而把有价值的旧备份挤掉。 +func BackupIfDue(db *gorm.DB, dbPath, dir string, keep int, interval time.Duration, now time.Time) (bool, error) { + if interval <= 0 { + return false, nil + } + + // 主库不存在或是空的(被人删了、或还没初始化)就别备 —— 否则会拿一份空快照 + // 顶掉保留窗口里的好备份。 + if info, err := os.Stat(dbPath); err != nil || info.Size() == 0 { + log.Printf("[backup] 主库不存在或为空,跳过本次备份: %s", dbPath) + return false, nil + } + + if newest, ok := newestBackupTime(dir); ok && now.Sub(newest) < interval { + return false, nil + } + + path, err := Backup(db, dir, keep, now) + if err != nil { + return false, err + } + log.Printf("[backup] 已备份到 %s", path) + return true, nil +} + +// newestBackupTime 目录里最新一份备份的时间,取自文件名而非 mtime —— +// 文件被复制/搬动过时 mtime 会变,名字里的时间戳不会。 +func newestBackupTime(dir string) (time.Time, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return time.Time{}, false + } + var newest time.Time + found := false + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), backupPrefix) || !strings.HasSuffix(e.Name(), backupSuffix) { + continue + } + // 去掉前缀/后缀后是 <时间戳> 或 <时间戳>-2。时间戳长度固定, + // 所以按长度取前一段即可 —— 不能去找「-2」,时间戳 20260914-200000 本身就含它。 + raw := strings.TrimSuffix(strings.TrimPrefix(e.Name(), backupPrefix), backupSuffix) + if len(raw) < len(backupLayout) { + continue + } + t, err := time.ParseInLocation(backupLayout, raw[:len(backupLayout)], time.Local) + if err != nil { + continue + } + if !found || t.After(newest) { + newest, found = t, true + } + } + return newest, found +} + +// StartBackupLoop 起一个后台协程做定期备份:启动时先补一次,之后每小时检查是否到期。 +// 备份失败只记日志,绝不因此中断服务 —— 它是保障手段,不是服务的一部分。 +func StartBackupLoop(db *gorm.DB, dbPath, dir string, keep, intervalHours int) { + interval := time.Duration(intervalHours) * time.Hour + if interval <= 0 { + log.Printf("[backup] 间隔为 0,已关闭定期备份") + return + } + + go func() { + run := func() { + if _, err := BackupIfDue(db, dbPath, dir, keep, interval, time.Now()); err != nil { + log.Printf("[backup] 备份失败: %v", err) + } + } + run() + + // 每小时醒一次看看到期没:比「睡 24 小时」更抗挂起/时钟跳变, + // 也比每天固定时刻更简单 —— 不关心几点,只关心隔了多久。 + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for range ticker.C { + run() + } + }() +} diff --git a/eai_agentplatform/backend-go/internal/store/backup_test.go b/eai_agentplatform/backend-go/internal/store/backup_test.go new file mode 100644 index 0000000..4a2ae63 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/backup_test.go @@ -0,0 +1,241 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "eai_agentplatform/backend/internal/model" +) + +// newBackupFixture 建一个带一条数据的临时库,外加一个备份目录。 +func newBackupFixture(t *testing.T) (*gorm.DB, string, string) { + t.Helper() + + root := t.TempDir() + dbPath := filepath.Join(root, "eai_agentplatform.db") + backupDir := filepath.Join(root, "backups") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("打开测试库失败: %v", err) + } + if err := db.AutoMigrate(&model.WorkerTask{}); err != nil { + t.Fatalf("建表失败: %v", err) + } + if err := db.Create(&model.WorkerTask{ + SpecialistKey: "general-assistant", + Title: "备份验证", + }).Error; err != nil { + t.Fatalf("插数据失败: %v", err) + } + return db, dbPath, backupDir +} + +func backupNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + t.Fatalf("读备份目录失败: %v", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +func at(y int, mo time.Month, d, h, mi, s int) time.Time { + return time.Date(y, mo, d, h, mi, s, 0, time.Local) +} + +// 备份出来的必须是一份能打开、且有数据的完整副本 —— 不是拷了个空壳。 +func TestBackupProducesReadableCopy(t *testing.T) { + db, _, backupDir := newBackupFixture(t) + + path, err := Backup(db, backupDir, 7, at(2026, 9, 14, 16, 30, 0)) + if err != nil { + t.Fatalf("备份失败: %v", err) + } + if err := verifyBackup(path); err != nil { + t.Fatalf("备份产物校验不过: %v", err) + } + + // 打开副本,数据得在 + copied, err := gorm.Open(sqlite.Open(path), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("打开备份库失败: %v", err) + } + var count int64 + if err := copied.Model(&model.WorkerTask{}).Count(&count).Error; err != nil { + t.Fatalf("查备份库失败: %v", err) + } + if count != 1 { + t.Errorf("备份库里有 %d 条任务,期望 1 条", count) + } +} + +// 只保留最近 keep 份,且删的是最旧的那几份。 +func TestBackupPrunesOldest(t *testing.T) { + db, _, backupDir := newBackupFixture(t) + + times := []time.Time{ + at(2026, 9, 10, 10, 0, 0), + at(2026, 9, 11, 10, 0, 0), + at(2026, 9, 12, 10, 0, 0), + at(2026, 9, 13, 10, 0, 0), + at(2026, 9, 14, 10, 0, 0), + } + for _, now := range times { + if _, err := Backup(db, backupDir, 3, now); err != nil { + t.Fatalf("备份失败: %v", err) + } + } + + names := backupNames(t, backupDir) + if len(names) != 3 { + t.Fatalf("保留了 %d 份,期望 3 份: %v", len(names), names) + } + for _, want := range []string{ + backupPrefix + "20260912-100000" + backupSuffix, + backupPrefix + "20260913-100000" + backupSuffix, + backupPrefix + "20260914-100000" + backupSuffix, + } { + if _, err := os.Stat(filepath.Join(backupDir, want)); err != nil { + t.Errorf("最近这份不该被删: %s", want) + } + } +} + +// 目录里不是我们产的文件,一个都不能碰。 +func TestBackupLeavesForeignFilesAlone(t *testing.T) { + db, _, backupDir := newBackupFixture(t) + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatal(err) + } + keep := []string{"notes.txt", "老备份.db", "eai_agentplatform-20260101-000000.db.bak"} + for _, name := range keep { + if err := os.WriteFile(filepath.Join(backupDir, name), []byte("别删我"), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := Backup(db, backupDir, 1, at(2026, 9, 14, 16, 0, 0)); err != nil { + t.Fatalf("备份失败: %v", err) + } + for _, name := range keep { + if _, err := os.Stat(filepath.Join(backupDir, name)); err != nil { + t.Errorf("不该被删的文件没了: %s", name) + } + } +} + +// 同一秒内备两次不能失败,也不能互相覆盖。 +func TestBackupSameSecondDoesNotCollide(t *testing.T) { + db, _, backupDir := newBackupFixture(t) + now := at(2026, 9, 14, 16, 0, 0) + + first, err := Backup(db, backupDir, 7, now) + if err != nil { + t.Fatalf("第一次备份失败: %v", err) + } + second, err := Backup(db, backupDir, 7, now) + if err != nil { + t.Fatalf("同一秒的第二次备份失败: %v", err) + } + if first == second { + t.Fatalf("两次备份指向同一个文件: %s", first) + } + if names := backupNames(t, backupDir); len(names) != 2 { + t.Errorf("应有 2 份备份,实际 %d: %v", len(names), names) + } +} + +// 间隔没到就不重复备 —— 这是防「进程反复重启把备份历史冲掉」的那道闸。 +func TestBackupIfDueRespectsInterval(t *testing.T) { + db, dbPath, backupDir := newBackupFixture(t) + interval := 24 * time.Hour + base := at(2026, 9, 14, 10, 0, 0) + + did, err := BackupIfDue(db, dbPath, backupDir, 7, interval, base) + if err != nil || !did { + t.Fatalf("首次应备份: did=%v err=%v", did, err) + } + + // 一小时后重启:没到期,不该再产一份 + did, err = BackupIfDue(db, dbPath, backupDir, 7, interval, base.Add(time.Hour)) + if err != nil { + t.Fatalf("未到期时出错: %v", err) + } + if did { + t.Error("距上次不足 24 小时,不该再备份") + } + if n := len(backupNames(t, backupDir)); n != 1 { + t.Errorf("未到期却多出了备份,现有 %d 份", n) + } + + // 25 小时后:到期了,补一份 + did, err = BackupIfDue(db, dbPath, backupDir, 7, interval, base.Add(25*time.Hour)) + if err != nil || !did { + t.Fatalf("到期应备份: did=%v err=%v", did, err) + } + if n := len(backupNames(t, backupDir)); n != 2 { + t.Errorf("到期后应有 2 份,实际 %d 份", n) + } +} + +// 主库为空(被人删了/还没初始化)时不备 —— 别拿空快照顶掉保留窗口里的好备份。 +func TestBackupIfDueSkipsEmptySource(t *testing.T) { + db, dbPath, backupDir := newBackupFixture(t) + if err := os.Remove(dbPath); err != nil { + t.Fatal(err) + } + + did, err := BackupIfDue(db, dbPath, backupDir, 7, 24*time.Hour, at(2026, 9, 14, 10, 0, 0)) + if err != nil { + t.Fatalf("不该报错,只该跳过: %v", err) + } + if did { + t.Error("主库不存在时不该备份") + } + if names := backupNames(t, backupDir); len(names) != 0 { + t.Errorf("不该产出备份,实际 %v", names) + } +} + +// 时间戳取自文件名,不受文件被搬动导致的 mtime 变化影响。 +func TestNewestBackupTimeIgnoresMtime(t *testing.T) { + db, _, backupDir := newBackupFixture(t) + if _, err := Backup(db, backupDir, 7, at(2026, 9, 10, 8, 0, 0)); err != nil { + t.Fatal(err) + } + newer, err := Backup(db, backupDir, 7, at(2026, 9, 14, 8, 0, 0)) + if err != nil { + t.Fatal(err) + } + // 把最新的那份 mtime 改成很久以前,判定仍应认它是新的 + old := at(2020, 1, 1, 0, 0, 0) + if err := os.Chtimes(newer, old, old); err != nil { + t.Fatal(err) + } + + got, ok := newestBackupTime(backupDir) + if !ok { + t.Fatal("没认出任何备份") + } + if want := at(2026, 9, 14, 8, 0, 0); !got.Equal(want) { + t.Errorf("最新备份时间 = %v,期望 %v", got, want) + } +}