feat: SQLite 定期备份内置进二进制

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 <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-14 16:33:05 +08:00
co-authored by Claude Code
parent f197dbd206
commit 20df2ff06e
8 changed files with 561 additions and 0 deletions
@@ -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()
}
}()
}
@@ -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)
}
}