diff --git a/server/internal/backup/file_runner.go b/server/internal/backup/file_runner.go index 37f6635..274178a 100644 --- a/server/internal/backup/file_runner.go +++ b/server/internal/backup/file_runner.go @@ -128,13 +128,19 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R } if currentInfo.Mode().IsRegular() { - file, err := os.Open(currentPath) - if err != nil { - return err - } - defer file.Close() - if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF { - return err + // 每个文件在独立作用域内打开并关闭,避免在大目录树中累积打开的文件句柄。 + if copyErr := func() error { + file, err := os.Open(currentPath) + if err != nil { + return err + } + defer file.Close() + if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF { + return err + } + return nil + }(); copyErr != nil { + return copyErr } fileCount++ if fileCount%100 == 0 { diff --git a/server/internal/backup/retention/service_test.go b/server/internal/backup/retention/service_test.go index a3b7631..a1e8124 100644 --- a/server/internal/backup/retention/service_test.go +++ b/server/internal/backup/retention/service_test.go @@ -45,6 +45,9 @@ func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.Ba func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) { return r.records, nil } +func (r *fakeRecordRepository) CountDependentDifferentials(context.Context, uint) (int64, error) { + return 0, 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) { diff --git a/server/internal/repository/backup_record_manifest_test.go b/server/internal/repository/backup_record_manifest_test.go new file mode 100644 index 0000000..e519c1c --- /dev/null +++ b/server/internal/repository/backup_record_manifest_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + "testing" + "time" + + "backupx/server/internal/model" +) + +// 列表查询应省略 Manifest 列(避免拖出大 JSON),而 FindByID 仍保留(内容浏览/差异基线需要)。 +func TestListSuccessfulByTaskOmitsManifest(t *testing.T) { + ctx := context.Background() + repo := newBackupRecordTestRepository(t) + rec := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, Manifest: `{"entries":[{"p":"x"}]}`, StartedAt: time.Now().UTC()} + if err := repo.Create(ctx, rec); err != nil { + t.Fatalf("create: %v", err) + } + + items, err := repo.ListSuccessfulByTask(ctx, 1) + if err != nil { + t.Fatalf("ListSuccessfulByTask: %v", err) + } + if len(items) == 0 { + t.Fatal("expected at least one record") + } + for _, it := range items { + if it.Manifest != "" { + t.Fatalf("ListSuccessfulByTask must omit Manifest, got %q", it.Manifest) + } + } + + full, err := repo.FindByID(ctx, rec.ID) + if err != nil || full == nil { + t.Fatalf("FindByID: %v / %v", full, err) + } + if full.Manifest == "" { + t.Fatal("FindByID must retain Manifest (browse/diff depend on it)") + } +} + +// 仅统计「成功且依赖给定全量」的差异备份:失败的或依赖其他全量的不计入。 +func TestCountDependentDifferentials(t *testing.T) { + ctx := context.Background() + repo := newBackupRecordTestRepository(t) + now := time.Now().UTC() + base := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, StartedAt: now} + if err := repo.Create(ctx, base); err != nil { + t.Fatalf("create base: %v", err) + } + mk := func(status, kind string, baseID uint) { + if err := repo.Create(ctx, &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: status, BackupKind: kind, BaseRecordID: baseID, StartedAt: now}); err != nil { + t.Fatalf("create dependent: %v", err) + } + } + mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入 + mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入 + mk(model.BackupRecordStatusFailed, model.BackupKindDifferential, base.ID) // 失败 → 不计 + mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, 99999) // 依赖其他全量 → 不计 + + n, err := repo.CountDependentDifferentials(ctx, base.ID) + if err != nil { + t.Fatalf("CountDependentDifferentials: %v", err) + } + if n != 2 { + t.Fatalf("want 2 dependent successful differentials, got %d", n) + } +} diff --git a/server/internal/repository/backup_record_repository.go b/server/internal/repository/backup_record_repository.go index 523df7a..07f3501 100644 --- a/server/internal/repository/backup_record_repository.go +++ b/server/internal/repository/backup_record_repository.go @@ -40,6 +40,7 @@ type BackupRecordRepository interface { ListRecent(context.Context, int) ([]model.BackupRecord, error) ListByTask(context.Context, uint) ([]model.BackupRecord, error) ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error) + CountDependentDifferentials(context.Context, uint) (int64, error) Count(context.Context) (int64, error) CountSince(context.Context, time.Time) (int64, error) CountSuccessSince(context.Context, time.Time) (int64, error) @@ -57,7 +58,8 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository { } func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) { - query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc") + // Omit("Manifest"):列表不需要可能很大的清单 JSON,避免每行拖出该 TEXT 列。 + query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc") if options.TaskID != nil { query = query.Where("task_id = ?", *options.TaskID) } @@ -125,7 +127,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int) limit = 10 } var items []model.BackupRecord - if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil { + if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil { return nil, err } return items, nil @@ -133,7 +135,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int) func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) { var items []model.BackupRecord - if err := r.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil { + if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil { return nil, err } return items, nil @@ -141,12 +143,21 @@ func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) { var items []model.BackupRecord - if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil { + if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil { return nil, err } return items, nil } +// CountDependentDifferentials 统计依赖某全量记录(作为基线)的成功差异备份数量。 +func (r *GormBackupRecordRepository) CountDependentDifferentials(ctx context.Context, baseID uint) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.BackupRecord{}). + Where("base_record_id = ? AND backup_kind = ? AND status = ?", baseID, model.BackupKindDifferential, model.BackupRecordStatusSuccess). + Count(&count).Error + return count, err +} + func (r *GormBackupRecordRepository) Count(ctx context.Context) (int64, error) { var count int64 if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Count(&count).Error; err != nil { diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go index d0cb69b..76066b2 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -280,6 +280,17 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint return apperror.BadRequest("BACKUP_RECORD_LOCKED", "该备份已保留锁定(法律保留),请先解锁再删除", nil) } + // 差异链保护:禁止删除仍被差异备份依赖的全量,否则这些差异将无法恢复(与保留清理的保护一致)。 + if record.BackupKind == model.BackupKindFull { + deps, depErr := s.records.CountDependentDifferentials(ctx, record.ID) + if depErr != nil { + return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法检查差异备份依赖", depErr) + } + if deps > 0 { + return apperror.BadRequest("BACKUP_RECORD_HAS_DEPENDENTS", + fmt.Sprintf("该全量备份仍有 %d 个差异备份依赖它,删除会导致这些差异无法恢复。请先删除相关差异备份或等待其过期。", deps), nil) + } + } if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil { return err } else if !remote && strings.TrimSpace(record.StoragePath) != "" { @@ -598,14 +609,19 @@ func (s *BackupExecutionService) resolveDifferentialBase(ctx context.Context, ta cutoff := time.Now().Add(-time.Duration(intervalDays) * 24 * time.Hour) for i := range records { rec := records[i] - if rec.BackupKind != model.BackupKindFull || strings.TrimSpace(rec.Manifest) == "" { + if rec.BackupKind != model.BackupKindFull { continue } // 最近的全量已超过强制全量间隔 → 触发新全量,限制差异链跨度与单个差异体积。 if rec.StartedAt.Before(cutoff) { return 0, backup.Manifest{}, false } - manifest, decErr := backup.DecodeManifest([]byte(rec.Manifest)) + // 列表查询已省略 Manifest 列,这里按需单独加载最近全量的清单(FindByID 含 Manifest)。 + full, ferr := s.records.FindByID(ctx, rec.ID) + if ferr != nil || full == nil || strings.TrimSpace(full.Manifest) == "" { + return 0, backup.Manifest{}, false + } + manifest, decErr := backup.DecodeManifest([]byte(full.Manifest)) if decErr != nil || len(manifest.Entries) == 0 { return 0, backup.Manifest{}, false }