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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user