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