Files
BackupX/server/internal/backup/retention/service.go
Wu Qing 386f12a11b feat(backup): 备份保留锁定 / 法律保留(豁免清理删除 + 记录页锁定) (#84)
新增保留锁定:锁定的备份豁免保留期清理与手动删除(迁移基线/合规快照/取证)。model+Locked、retention 剔除锁定记录、DeleteRecord 拒绝删除、PUT /backup/records/:id/lock、记录页锁定/解锁操作与标识。go test、tsc+vite、运行时路由验证均通过。
2026-05-27 13:59:05 +08:00

136 lines
3.8 KiB
Go

package retention
import (
"context"
"fmt"
"strings"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
)
// collectDirPrefixes 从待删除的记录中提取唯一的父目录前缀。
func collectDirPrefixes(records []model.BackupRecord) []string {
seen := make(map[string]struct{})
var prefixes []string
for _, record := range records {
path := strings.TrimSpace(record.StoragePath)
if path == "" {
continue
}
idx := strings.LastIndex(path, "/")
if idx <= 0 {
continue
}
dir := path[:idx]
if _, ok := seen[dir]; !ok {
seen[dir] = struct{}{}
prefixes = append(prefixes, dir)
}
}
return prefixes
}
type CleanupResult struct {
DeletedRecords int
DeletedObjects int
Warnings []string
}
type Service struct {
records repository.BackupRecordRepository
now func() time.Time
}
func NewService(records repository.BackupRecordRepository) *Service {
return &Service{records: records, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider storage.StorageProvider) (*CleanupResult, error) {
if task == nil {
return nil, fmt.Errorf("backup task is required")
}
records, err := s.records.ListSuccessfulByTask(ctx, task.ID)
if err != nil {
return nil, fmt.Errorf("list successful records: %w", err)
}
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
result := &CleanupResult{}
for _, record := range candidates {
if strings.TrimSpace(record.StoragePath) != "" {
if provider == nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider for cleanup", record.ID))
continue
}
if err := provider.Delete(ctx, record.StoragePath); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s failed: %v", record.StoragePath, err))
continue
}
result.DeletedObjects++
}
if err := s.records.Delete(ctx, record.ID); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete backup record %d failed: %v", record.ID, err))
continue
}
result.DeletedRecords++
}
// 清理空目录:收集被删除文件的父目录,尝试移除空目录
if dirCleaner, ok := provider.(storage.StorageDirCleaner); ok && result.DeletedObjects > 0 {
prefixes := collectDirPrefixes(candidates)
for _, prefix := range prefixes {
if err := dirCleaner.RemoveEmptyDirs(ctx, prefix); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s: %v", prefix, err))
}
}
}
return result, nil
}
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
// 保留锁定(法律保留)的记录永不参与清理:先从候选集中剔除,
// 锁定备份既不被删除,也不占用 maxBackups 轮转名额。
if hasLocked(records) {
unlocked := make([]model.BackupRecord, 0, len(records))
for _, r := range records {
if !r.Locked {
unlocked = append(unlocked, r)
}
}
records = unlocked
}
selected := make(map[uint]model.BackupRecord)
if maxBackups > 0 && len(records) > maxBackups {
for _, record := range records[maxBackups:] {
selected[record.ID] = record
}
}
if retentionDays > 0 {
cutoff := now.AddDate(0, 0, -retentionDays)
for _, record := range records {
if record.CompletedAt != nil && record.CompletedAt.Before(cutoff) {
selected[record.ID] = record
}
}
}
result := make([]model.BackupRecord, 0, len(selected))
for _, record := range records {
if selectedRecord, ok := selected[record.ID]; ok {
result = append(result, selectedRecord)
}
}
return result
}
func hasLocked(records []model.BackupRecord) bool {
for i := range records {
if records[i].Locked {
return true
}
}
return false
}