mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-12 02:20:36 +08:00
1. 保留策略清理后自动删除空文件夹(新增 StorageDirCleaner 接口) 2. 备份任务删除时清理远端文件但保留备份记录 3. 节点管理修复:本机 IP/版本检测、Heartbeat OS/Arch 修正、新增编辑功能 4. 审计日志规范化:统一格式、丰富详情、节点操作增加审计记录 5. 系统设置移除一键更新操作,仅保留版本检查 6. Rclone 配置项分层展示(必填 + 高级可选折叠) 7. DirectoryPicker 目录选择器样式优化
116 lines
3.4 KiB
Go
116 lines
3.4 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 {
|
|
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
|
|
}
|