mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-05 23:47:33 +08:00
first commit
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
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++
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type fakeRecordRepository struct {
|
||||
records []model.BackupRecord
|
||||
deleted []uint
|
||||
deleteErrs map[uint]error
|
||||
}
|
||||
|
||||
func (r *fakeRecordRepository) List(context.Context, repository.BackupRecordListOptions) ([]model.BackupRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) Create(context.Context, *model.BackupRecord) error { return nil }
|
||||
func (r *fakeRecordRepository) Update(context.Context, *model.BackupRecord) error { return nil }
|
||||
func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {
|
||||
if err := r.deleteErrs[id]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.deleted = append(r.deleted, id)
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRecordRepository) ListRecent(context.Context, int) ([]model.BackupRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
|
||||
return r.records, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) SumFileSize(context.Context) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) TimelineSince(context.Context, time.Time) ([]repository.BackupTimelinePoint, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) StorageUsage(context.Context) ([]repository.BackupStorageUsageItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeProvider struct {
|
||||
deleted []string
|
||||
failKey string
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Type() string { return storage.ProviderTypeLocalDisk }
|
||||
func (p *fakeProvider) TestConnection(context.Context) error { return nil }
|
||||
func (p *fakeProvider) Upload(context.Context, string, io.Reader, int64, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (p *fakeProvider) Download(context.Context, string) (io.ReadCloser, error) { return nil, nil }
|
||||
func (p *fakeProvider) Delete(_ context.Context, objectKey string) error {
|
||||
if objectKey == p.failKey {
|
||||
return fmt.Errorf("delete failed")
|
||||
}
|
||||
p.deleted = append(p.deleted, objectKey)
|
||||
return nil
|
||||
}
|
||||
func (p *fakeProvider) List(context.Context, string) ([]storage.ObjectInfo, error) { return nil, nil }
|
||||
|
||||
func TestSelectRecordsToDelete(t *testing.T) {
|
||||
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||
completedNew := now.Add(-24 * time.Hour)
|
||||
completedOld := now.Add(-15 * 24 * time.Hour)
|
||||
records := []model.BackupRecord{
|
||||
{ID: 3, CompletedAt: &completedNew},
|
||||
{ID: 2, CompletedAt: &completedNew},
|
||||
{ID: 1, CompletedAt: &completedOld},
|
||||
}
|
||||
selected := selectRecordsToDelete(records, 7, 2, now)
|
||||
if len(selected) != 1 || selected[0].ID != 1 {
|
||||
t.Fatalf("unexpected selected records: %#v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDeletesExpiredRecords(t *testing.T) {
|
||||
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||
completedNew := now.Add(-24 * time.Hour)
|
||||
completedOld := now.Add(-15 * 24 * time.Hour)
|
||||
repo := &fakeRecordRepository{records: []model.BackupRecord{
|
||||
{ID: 3, TaskID: 1, StoragePath: "records/3", CompletedAt: &completedNew},
|
||||
{ID: 2, TaskID: 1, StoragePath: "records/2", CompletedAt: &completedNew},
|
||||
{ID: 1, TaskID: 1, StoragePath: "records/1", CompletedAt: &completedOld},
|
||||
}}
|
||||
provider := &fakeProvider{}
|
||||
service := NewService(repo)
|
||||
service.now = func() time.Time { return now }
|
||||
result, err := service.Cleanup(context.Background(), &model.BackupTask{ID: 1, RetentionDays: 7, MaxBackups: 2}, provider)
|
||||
if err != nil {
|
||||
t.Fatalf("Cleanup returned error: %v", err)
|
||||
}
|
||||
if result.DeletedRecords != 1 || result.DeletedObjects != 1 {
|
||||
t.Fatalf("unexpected cleanup result: %#v", result)
|
||||
}
|
||||
if len(repo.deleted) != 1 || repo.deleted[0] != 1 {
|
||||
t.Fatalf("unexpected deleted records: %#v", repo.deleted)
|
||||
}
|
||||
if len(provider.deleted) != 1 || provider.deleted[0] != "records/1" {
|
||||
t.Fatalf("unexpected deleted objects: %#v", provider.deleted)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user