package export import ( "database/sql" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // DefaultBackupKeep 是滚动保留的备份份数(见 08-安全与可靠性 第 6 节)。 const DefaultBackupKeep = 20 // BackupOptions 描述一次备份。 type BackupOptions struct { // Dir 是备份目录,通常为 /backups。 Dir string // Prefix 是文件名前缀,用于区分不同库(如 event / system)。 Prefix string // Reason 记录触发原因:manual / publish / auto。 Reason string // Keep 是滚动保留份数,<=0 时用 DefaultBackupKeep。 Keep int // SkipPrune 为 true 时只备份不清理,供「手动留档」场景使用。 SkipPrune bool // Now 便于测试注入固定时间;零值取当前时间。 Now time.Time } // BackupInfo 是一份备份的描述。 type BackupInfo struct { Path string `json:"path"` Name string `json:"name"` Size int64 `json:"size"` SHA256 string `json:"sha256"` Reason string `json:"reason"` CreatedAt time.Time `json:"createdAt"` } // Backup 用 VACUUM INTO 生成一致性快照,并按 Keep 滚动清理旧备份。 // VACUUM INTO 由 SQLite 自己保证快照一致,服务运行中也能安全备份。 func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) { if db == nil { return BackupInfo{}, ErrNoDatabase } dir := strings.TrimSpace(opts.Dir) if dir == "" { return BackupInfo{}, errors.New("export: 备份目录为空") } if err := os.MkdirAll(dir, 0o755); err != nil { return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err) } prefix := strings.TrimSpace(opts.Prefix) if prefix == "" { prefix = "db" } reason := strings.TrimSpace(opts.Reason) if reason == "" { reason = "manual" } now := nowOr(opts.Now) path := uniqueBackupPath(dir, prefix, reason, now) if err := vacuumInto(db, path); err != nil { return BackupInfo{}, err } sum, size, err := hashFile(path) if err != nil { return BackupInfo{}, err } // 文件名里的时间戳就是备份时间,文件时间与之一致,排序不会错乱。 _ = os.Chtimes(path, now, now) info := BackupInfo{ Path: path, Name: filepath.Base(path), Size: size, SHA256: sum, Reason: reason, CreatedAt: now, } if !opts.SkipPrune { if _, err := PruneBackups(dir, prefix, opts.Keep); err != nil { return info, err } } return info, nil } // BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish, // 便于在备份列表里一眼看出哪些是发布节点留的。 func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) { if strings.TrimSpace(opts.Reason) == "" { opts.Reason = "publish" } return Backup(db, opts) } // ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。 // 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。 func ListBackups(dir, prefix string) ([]BackupInfo, error) { entries, err := os.ReadDir(dir) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("export: 读取备份目录 %s: %w", dir, err) } prefix = strings.TrimSpace(prefix) out := make([]BackupInfo, 0, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") { continue } if prefix != "" && !strings.HasPrefix(e.Name(), prefix+"-") { continue } info, err := e.Info() if err != nil { return nil, fmt.Errorf("export: 读取备份信息 %s: %w", e.Name(), err) } out = append(out, BackupInfo{ Path: filepath.Join(dir, e.Name()), Name: e.Name(), Size: info.Size(), Reason: reasonFromName(e.Name(), prefix), CreatedAt: info.ModTime(), }) } sort.Slice(out, func(i, j int) bool { if !out[i].CreatedAt.Equal(out[j].CreatedAt) { return out[i].CreatedAt.After(out[j].CreatedAt) } return out[i].Name > out[j].Name }) return out, nil } // LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。 func LatestBackup(dir, prefix string) (BackupInfo, bool, error) { list, err := ListBackups(dir, prefix) if err != nil || len(list) == 0 { return BackupInfo{}, false, err } return list[0], true, nil } // PruneBackups 只保留最近 keep 份,返回被删除的文件路径。 func PruneBackups(dir, prefix string, keep int) ([]string, error) { if keep <= 0 { keep = DefaultBackupKeep } list, err := ListBackups(dir, prefix) if err != nil { return nil, err } if len(list) <= keep { return nil, nil } removed := make([]string, 0, len(list)-keep) for _, b := range list[keep:] { if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) { return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err) } removed = append(removed, b.Path) } return removed, nil } func uniqueBackupPath(dir, prefix, reason string, now time.Time) string { base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405")) p := filepath.Join(dir, base+".db") for i := 2; ; i++ { if _, err := os.Stat(p); os.IsNotExist(err) { return p } p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i)) } } // reasonFromName 从 ---.db 里取回原因。 func reasonFromName(name, prefix string) string { base := strings.TrimSuffix(name, filepath.Ext(name)) if prefix != "" { base = strings.TrimPrefix(base, prefix+"-") } parts := strings.Split(base, "-") if len(parts) >= 3 { return strings.Join(parts[:len(parts)-2], "-") } return "" }