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
+1
View File
@@ -65,6 +65,7 @@
| D21 | 前端建立「冻结归档」机制:停用代码不删除,集中到 `frontend/src/_frozen/`(统一 `.frozen` 后缀 + `FROZEN.md` 登记表,逐条记录原位置/冻结原因/外部依赖/**解冻条件**/解冻步骤)。首批冻结:工作区胶囊条(外壳重构后成了冗余的第二套导航)及外壳重构遗留的四个孤儿文件。**D19 的站内通知只冻前端 UI** —— 后端四个接口保留,`store`/`api` 层保留(`BusinessAppPage` 仍在用),解冻为纯前端工作量 | 已定(见 `frontend/src/_frozen/FROZEN.md`) | | 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 落地) | | 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 份 | 已定 | | 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. 文档索引 ## 4. 文档索引
@@ -3,6 +3,7 @@ package main
import ( import (
"log" "log"
"os" "os"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,6 +21,9 @@ func main() {
case "-reset-admin": case "-reset-admin":
resetAdmin(os.Args[2:]) resetAdmin(os.Args[2:])
return return
case "-backup":
backupOnce()
return
} }
} }
@@ -32,6 +36,12 @@ func main() {
log.Fatalf("种子数据初始化失败: %v", err) log.Fatalf("种子数据初始化失败: %v", err)
} }
// 定期备份:SQLite 是单文件且无外部副本,坏一份就是全丢。
// 失败只在日志里报,绝不因此拦下服务。
if cfg.BackupEnabled {
store.StartBackupLoop(store.DB, cfg.DBPath, cfg.BackupDir, cfg.BackupKeep, cfg.BackupIntervalHours)
}
r := gin.Default() r := gin.Default()
api.RegisterRoutes(r, cfg) 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 <新密码> // resetAdmin 交付前重置管理员密码:eai_agentplatform-server -reset-admin <新密码>
func resetAdmin(args []string) { func resetAdmin(args []string) {
if len(args) < 1 || args[0] == "" { if len(args) < 1 || args[0] == "" {
@@ -29,6 +29,7 @@
├── .env # JWT 密钥等(交付前生成,勿提交源码库) ├── .env # JWT 密钥等(交付前生成,勿提交源码库)
├── data/ ├── data/
│ ├── eai_agentplatform.db # SQLite(首启自动建) │ ├── eai_agentplatform.db # SQLite(首启自动建)
│ ├── backups/ # 定期备份(服务自动维护,见第 7 节)
│ └── kb_data/ # 素材 + 提取缓存 │ └── kb_data/ # 素材 + 提取缓存
├── knowledge_source/ # 知识源 Markdown ├── knowledge_source/ # 知识源 Markdown
└── (前端静态资源由 Nginx 托管,见 ../docs/deploy.md) └── (前端静态资源由 Nginx 托管,见 ../docs/deploy.md)
@@ -131,3 +132,47 @@ SQL
- [ ] 管理员密码非 `admin123` - [ ] 管理员密码非 `admin123`
- [ ] `.bash_history` 已清空、无 `.git` - [ ] `.bash_history` 已清空、无 `.git`
- [ ] 测试账号(zhangsan 等)、演示素材、演示考试记录已删除 - [ ] 测试账号(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/` 里会有原型机自己的历史数据(测试账号、演示素材),
整盘克隆会原样带到客户机器上。清理时一并删掉该目录内容,让客户机器从干净状态开始。
恢复之后想核对内容,直接看文件时间戳和大小即可 —— 文件名里的时间就是备份时刻。
@@ -43,6 +43,8 @@ done
step "3. 清理测试/演示数据(保留生产库结构,清空内容)" step "3. 清理测试/演示数据(保留生产库结构,清空内容)"
echo " - 提示:测试用户 zhangsan / 演示素材 / 演示考试记录等由管理员在 Web 界面手动清理," echo " - 提示:测试用户 zhangsan / 演示素材 / 演示考试记录等由管理员在 Web 界面手动清理,"
echo " 或在克隆前用 sqlite3 执行(见 DELIVERY.md 第 4 节)。脚本不自动清库,避免误删正式数据。" echo " 或在克隆前用 sqlite3 执行(见 DELIVERY.md 第 4 节)。脚本不自动清库,避免误删正式数据。"
echo " - 别漏了 $APP_DIR/data/backups/ :里面是原型机自己的历史快照,含同一批测试数据,"
echo " 整盘克隆会一起带到客户机器上。确认无用后手工清空该目录(见 DELIVERY.md 第 7 节)。"
step "4. 清空日志与 shell 历史" step "4. 清空日志与 shell 历史"
for f in "$APP_DIR/logs" "$APP_DIR"/*.log /var/log/eai_agentplatform*.log; do for f in "$APP_DIR/logs" "$APP_DIR"/*.log /var/log/eai_agentplatform*.log; do
@@ -21,6 +21,13 @@ EMBED_MODEL=bge-m3
KB_DATA_DIR=data/kb_data KB_DATA_DIR=data/kb_data
KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/knowledge_source 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_DOC=209715200
FILE_MAX_SIZE_VIDEO=2147483648 FILE_MAX_SIZE_VIDEO=2147483648
@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
) )
// Config 全局配置,从环境变量读取(带默认值)。 // Config 全局配置,从环境变量读取(带默认值)。
@@ -25,6 +26,13 @@ type Config struct {
KBDataDir string KBDataDir string
KnowledgeSourceDir string KnowledgeSourceDir string
// 定期备份。SQLite 是单文件、又没有任何外部主从,坏一份就是全丢,
// 所以默认开着:启动补一次,之后每 BackupIntervalHours 小时一次,保留最近 N 份。
BackupEnabled bool
BackupDir string
BackupKeep int
BackupIntervalHours int
FileMaxDoc int64 FileMaxDoc int64
FileMaxVideo int64 FileMaxVideo int64
ChunkThreshold int64 ChunkThreshold int64
@@ -61,6 +69,13 @@ func Load() *Config {
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")), KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")), 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), FileMaxDoc: getenvInt64("FILE_MAX_SIZE_DOC", 209715200),
FileMaxVideo: getenvInt64("FILE_MAX_SIZE_VIDEO", 2147483648), FileMaxVideo: getenvInt64("FILE_MAX_SIZE_VIDEO", 2147483648),
ChunkThreshold: getenvInt64("CHUNK_THRESHOLD", 104857600), ChunkThreshold: getenvInt64("CHUNK_THRESHOLD", 104857600),
@@ -94,6 +109,20 @@ func getenv(key, def string) string {
return def 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 { func getenvInt(key string, def int) int {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil { 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)
}
}