mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-06 16:06:45 +08:00
feat(backup): 备份保留锁定 / 法律保留(豁免清理删除 + 记录页锁定) (#84)
新增保留锁定:锁定的备份豁免保留期清理与手动删除(迁移基线/合规快照/取证)。model+Locked、retention 剔除锁定记录、DeleteRecord 拒绝删除、PUT /backup/records/:id/lock、记录页锁定/解锁操作与标识。go test、tsc+vite、运行时路由验证均通过。
This commit is contained in:
@@ -91,6 +91,17 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
||||
}
|
||||
|
||||
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
|
||||
// 保留锁定(法律保留)的记录永不参与清理:先从候选集中剔除,
|
||||
// 锁定备份既不被删除,也不占用 maxBackups 轮转名额。
|
||||
if hasLocked(records) {
|
||||
unlocked := make([]model.BackupRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
if !r.Locked {
|
||||
unlocked = append(unlocked, r)
|
||||
}
|
||||
}
|
||||
records = unlocked
|
||||
}
|
||||
selected := make(map[uint]model.BackupRecord)
|
||||
if maxBackups > 0 && len(records) > maxBackups {
|
||||
for _, record := range records[maxBackups:] {
|
||||
@@ -113,3 +124,12 @@ func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxB
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasLocked(records []model.BackupRecord) bool {
|
||||
for i := range records {
|
||||
if records[i].Locked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -93,6 +93,30 @@ func TestSelectRecordsToDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectRecordsToDelete_SkipsLocked 验证保留锁定(法律保留)的记录永不被选中删除,
|
||||
// 即使它既超过保留期、又超过 maxBackups 名额。
|
||||
func TestSelectRecordsToDelete_SkipsLocked(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, Locked: true}, // 超期但锁定 → 不应删除
|
||||
}
|
||||
selected := selectRecordsToDelete(records, 7, 2, now)
|
||||
for _, r := range selected {
|
||||
if r.ID == 1 {
|
||||
t.Fatalf("locked record #1 must never be selected for deletion: %#v", selected)
|
||||
}
|
||||
}
|
||||
// 锁定记录不占 maxBackups 名额:未锁定仅 2 条,maxBackups=2 → 无超额删除,
|
||||
// 且无未锁定记录超期 → 选中集为空。
|
||||
if len(selected) != 0 {
|
||||
t.Fatalf("expected no deletions (locked excluded), got %#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)
|
||||
|
||||
Reference in New Issue
Block a user