feat(backup): 新增差异备份(differential)模式 (#88)

文件备份新增差异模式:仅打包自上次全量以来的变更并记录删除,恢复自动按全量+差异链还原。含基线解析、链式恢复、保留链保护与本机文件任务校验;清单/比对/删除/往返/保留保护单测全覆盖。
This commit is contained in:
Wu Qing
2026-05-27 19:03:40 +08:00
committed by GitHub
parent f584a0802a
commit 90b58d58d6
17 changed files with 761 additions and 60 deletions
@@ -64,6 +64,8 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
} else {
candidates = selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
}
// 差异链保护:保留仍被存活差异依赖的全量,避免删除基线后差异无法恢复。
candidates = protectDifferentialBases(records, candidates)
result := &CleanupResult{}
for _, record := range candidates {
if strings.TrimSpace(record.StoragePath) != "" {
@@ -97,6 +99,38 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
return result, nil
}
// protectDifferentialBases 从删除候选中剔除「仍被存活差异依赖的全量」,
// 避免删除基线后其差异备份失去依据、无法恢复。全量仅当其全部差异都已过期/删除时才会被清理。
func protectDifferentialBases(all []model.BackupRecord, candidates []model.BackupRecord) []model.BackupRecord {
deleting := make(map[uint]struct{}, len(candidates))
for _, r := range candidates {
deleting[r.ID] = struct{}{}
}
protected := make(map[uint]struct{})
for _, r := range all {
if r.BackupKind != model.BackupKindDifferential || r.BaseRecordID == 0 {
continue
}
if _, beingDeleted := deleting[r.ID]; beingDeleted {
continue // 该差异本身也将被删除,无需保护其基线
}
protected[r.BaseRecordID] = struct{}{}
}
if len(protected) == 0 {
return candidates
}
filtered := make([]model.BackupRecord, 0, len(candidates))
for _, r := range candidates {
if r.BackupKind == model.BackupKindFull {
if _, keep := protected[r.ID]; keep {
continue
}
}
filtered = append(filtered, r)
}
return filtered
}
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
// 保留锁定(法律保留)的记录永不参与清理:先从候选集中剔除,
// 锁定备份既不被删除,也不占用 maxBackups 轮转名额。