Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions server/internal/backup/file_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions server/internal/backup/retention/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions server/internal/repository/backup_record_manifest_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 15 additions & 4 deletions server/internal/repository/backup_record_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -125,28 +127,37 @@ 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
}

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
}

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 {
Expand Down
20 changes: 18 additions & 2 deletions server/internal/service/backup_execution_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) != "" {
Expand Down Expand Up @@ -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
}
Expand Down
Loading