mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 23:16:41 +08:00
feat(backup): GFS 分层保留策略(祖父-父-子)+ 任务表单配置 (#85)
新增 GFS 分层保留:按天/周/月/年保留代表性备份,各层级并集;任一>0 启用、全0 维持原策略(兼容),锁定记录豁免。后端 retention 算法+任务字段贯通,前端任务表单 GFS 配置。go test、tsc+vite 通过。
This commit is contained in:
@@ -3,6 +3,7 @@ package retention
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -56,7 +57,13 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list successful records: %w", err)
|
||||
}
|
||||
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
|
||||
var candidates []model.BackupRecord
|
||||
if gfsEnabled(task) {
|
||||
// GFS 策略:按天/周/月/年分层保留代表性备份,取代简单的天数/数量策略。
|
||||
candidates = selectGFSToDelete(records, task.KeepDaily, task.KeepWeekly, task.KeepMonthly, task.KeepYearly)
|
||||
} else {
|
||||
candidates = selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
|
||||
}
|
||||
result := &CleanupResult{}
|
||||
for _, record := range candidates {
|
||||
if strings.TrimSpace(record.StoragePath) != "" {
|
||||
@@ -133,3 +140,72 @@ func hasLocked(records []model.BackupRecord) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gfsEnabled 判定任务是否启用 GFS 分层保留(任一层级 > 0)。
|
||||
func gfsEnabled(task *model.BackupTask) bool {
|
||||
return task.KeepDaily > 0 || task.KeepWeekly > 0 || task.KeepMonthly > 0 || task.KeepYearly > 0
|
||||
}
|
||||
|
||||
func recordTime(r *model.BackupRecord) time.Time {
|
||||
if r.CompletedAt != nil {
|
||||
return *r.CompletedAt
|
||||
}
|
||||
return r.StartedAt
|
||||
}
|
||||
|
||||
func isoWeekKey(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%d-W%02d", y, w)
|
||||
}
|
||||
|
||||
// selectGFSToDelete 按 GFS(祖父-父-子)策略选出应删除的记录。
|
||||
//
|
||||
// 规则:对每个层级(天/周/月/年),在按时间降序排列后,保留最近 keep 个不同周期中
|
||||
// 每个周期最新的一份备份;各层级保留集合取并集即「保留集」,其余删除。
|
||||
// 锁定(法律保留)的记录始终排除在删除候选之外。
|
||||
func selectGFSToDelete(records []model.BackupRecord, daily, weekly, monthly, yearly int) []model.BackupRecord {
|
||||
active := make([]model.BackupRecord, 0, len(records))
|
||||
for i := range records {
|
||||
if !records[i].Locked {
|
||||
active = append(active, records[i])
|
||||
}
|
||||
}
|
||||
sort.SliceStable(active, func(i, j int) bool {
|
||||
return recordTime(&active[i]).After(recordTime(&active[j]))
|
||||
})
|
||||
|
||||
keep := make(map[uint]bool, len(active))
|
||||
keepTier := func(count int, key func(time.Time) string) {
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
periods := 0
|
||||
lastPeriod := ""
|
||||
havePrev := false
|
||||
for i := range active {
|
||||
p := key(recordTime(&active[i]))
|
||||
if havePrev && p == lastPeriod {
|
||||
continue // 同周期已保留代表(最新一份)
|
||||
}
|
||||
if periods >= count {
|
||||
break // 该层级已保留足够多的周期
|
||||
}
|
||||
keep[active[i].ID] = true
|
||||
lastPeriod = p
|
||||
havePrev = true
|
||||
periods++
|
||||
}
|
||||
}
|
||||
keepTier(daily, func(t time.Time) string { return t.Format("2006-01-02") })
|
||||
keepTier(weekly, isoWeekKey)
|
||||
keepTier(monthly, func(t time.Time) string { return t.Format("2006-01") })
|
||||
keepTier(yearly, func(t time.Time) string { return t.Format("2006") })
|
||||
|
||||
del := make([]model.BackupRecord, 0)
|
||||
for i := range active {
|
||||
if !keep[active[i].ID] {
|
||||
del = append(del, active[i])
|
||||
}
|
||||
}
|
||||
return del
|
||||
}
|
||||
|
||||
@@ -93,6 +93,81 @@ func TestSelectRecordsToDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func gfsRecord(id uint, ts time.Time, locked bool) model.BackupRecord {
|
||||
completed := ts
|
||||
return model.BackupRecord{ID: id, StartedAt: ts, CompletedAt: &completed, Locked: locked}
|
||||
}
|
||||
|
||||
func gfsDay(y, m, d, h int) time.Time {
|
||||
return time.Date(y, time.Month(m), d, h, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func deletedIDSet(records []model.BackupRecord) map[uint]bool {
|
||||
out := make(map[uint]bool, len(records))
|
||||
for i := range records {
|
||||
out[records[i].ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertDeleted(t *testing.T, del []model.BackupRecord, want ...uint) {
|
||||
t.Helper()
|
||||
got := deletedIDSet(del)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("deleted set size = %d %v, want %d %v", len(got), got, len(want), want)
|
||||
}
|
||||
for _, id := range want {
|
||||
if !got[id] {
|
||||
t.Fatalf("expected id %d to be deleted; got %v", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectGFSToDelete_DailyTier 验证按天分层:每天仅保留最新一份,且只保留最近 N 天。
|
||||
func TestSelectGFSToDelete_DailyTier(t *testing.T) {
|
||||
records := []model.BackupRecord{
|
||||
gfsRecord(5, gfsDay(2026, 3, 7, 12), false), // 今天,最新 → 保留
|
||||
gfsRecord(4, gfsDay(2026, 3, 7, 6), false), // 今天,较早 → 删除(非当天代表)
|
||||
gfsRecord(3, gfsDay(2026, 3, 6, 12), false), // 昨天 → 保留
|
||||
gfsRecord(2, gfsDay(2026, 3, 5, 12), false), // 前天 → 超出 daily=2 → 删除
|
||||
gfsRecord(1, gfsDay(2026, 3, 4, 12), false), // 更早 → 删除
|
||||
}
|
||||
del := selectGFSToDelete(records, 2, 0, 0, 0)
|
||||
assertDeleted(t, del, 4, 2, 1)
|
||||
}
|
||||
|
||||
// TestSelectGFSToDelete_TierUnion 验证多层级取并集:月度层级保留日度层级会删除的旧备份。
|
||||
func TestSelectGFSToDelete_TierUnion(t *testing.T) {
|
||||
records := []model.BackupRecord{
|
||||
gfsRecord(3, gfsDay(2026, 3, 7, 12), false), // 3 月(最新)
|
||||
gfsRecord(2, gfsDay(2026, 2, 15, 12), false), // 2 月
|
||||
gfsRecord(1, gfsDay(2026, 1, 15, 12), false), // 1 月
|
||||
}
|
||||
// daily=1 只留 ID3;monthly=2 留最近两个月(3 月=ID3、2 月=ID2)。并集={3,2},删除 ID1。
|
||||
del := selectGFSToDelete(records, 1, 0, 2, 0)
|
||||
assertDeleted(t, del, 1)
|
||||
}
|
||||
|
||||
// TestSelectGFSToDelete_SkipsLocked 验证锁定记录即使超出所有层级也永不删除。
|
||||
func TestSelectGFSToDelete_SkipsLocked(t *testing.T) {
|
||||
records := []model.BackupRecord{
|
||||
gfsRecord(3, gfsDay(2026, 3, 7, 12), false),
|
||||
gfsRecord(2, gfsDay(2026, 3, 6, 12), false),
|
||||
gfsRecord(1, gfsDay(2020, 1, 1, 12), true), // 远超 daily=1 但已锁定 → 不删
|
||||
}
|
||||
del := selectGFSToDelete(records, 1, 0, 0, 0)
|
||||
assertDeleted(t, del, 2) // 仅 ID2 被删;ID1 锁定豁免,ID3 为当日代表
|
||||
}
|
||||
|
||||
func TestGFSEnabled(t *testing.T) {
|
||||
if gfsEnabled(&model.BackupTask{}) {
|
||||
t.Fatal("empty GFS config should be disabled")
|
||||
}
|
||||
if !gfsEnabled(&model.BackupTask{KeepWeekly: 4}) {
|
||||
t.Fatal("KeepWeekly>0 should enable GFS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectRecordsToDelete_SkipsLocked 验证保留锁定(法律保留)的记录永不被选中删除,
|
||||
// 即使它既超过保留期、又超过 maxBackups 名额。
|
||||
func TestSelectRecordsToDelete_SkipsLocked(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user