feat(backup): 备份保留锁定 / 法律保留(豁免清理删除 + 记录页锁定) (#84)

新增保留锁定:锁定的备份豁免保留期清理与手动删除(迁移基线/合规快照/取证)。model+Locked、retention 剔除锁定记录、DeleteRecord 拒绝删除、PUT /backup/records/:id/lock、记录页锁定/解锁操作与标识。go test、tsc+vite、运行时路由验证均通过。
This commit is contained in:
Wu Qing
2026-05-27 13:59:05 +08:00
committed by GitHub
parent f807ce10e6
commit 386f12a11b
11 changed files with 266 additions and 26 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -161,6 +161,32 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true})
}
// SetLock 设置/解除备份记录的保留锁定(法律保留)。
func (h *BackupRecordHandler) SetLock(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_RECORD_LOCK_INVALID", "锁定参数不合法", err))
return
}
detail, err := h.service.SetLock(c.Request.Context(), id, input.Locked)
if err != nil {
response.Error(c, err)
return
}
action, desc := "unlock", fmt.Sprintf("解除备份记录保留锁定 (ID: %d)", id)
if input.Locked {
action, desc = "lock", fmt.Sprintf("设置备份记录保留锁定 (ID: %d)", id)
}
recordAudit(c, h.auditService, "backup_record", action, "backup_record", fmt.Sprintf("%d", id), "", desc)
response.Success(c, detail)
}
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`

View File

@@ -170,6 +170,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
backupRecords.PUT("/:id/lock", RequireNotViewer(), backupRecordHandler.SetLock)
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
// 创建恢复仍然走 POST /backup/records/:id/restore以源备份记录为触发点

View File

@@ -9,27 +9,30 @@ const (
)
type BackupRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
// NodeID 执行该次备份的节点0 = 本机 Master。用于集群中识别 local_disk 类型
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupRecord) TableName() string {

View File

@@ -276,6 +276,10 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
if record == nil {
return apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
if record.Locked {
return apperror.BadRequest("BACKUP_RECORD_LOCKED",
"该备份已保留锁定(法律保留),请先解锁再删除", nil)
}
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
return err
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {

View File

@@ -0,0 +1,104 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/backup"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
)
func newLockTestHarness(t *testing.T) (*BackupRecordService, *BackupExecutionService) {
t.Helper()
baseDir := t.TempDir()
sourceDir := filepath.Join(baseDir, "data")
storeDir := filepath.Join(baseDir, "store")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("lock-data"), 0o644); err != nil {
t.Fatal(err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatal(err)
}
cipher := codec.NewConfigCipher("lock-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
if err != nil {
t.Fatal(err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatal(err)
}
task := &model.BackupTask{Name: "lock-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatal(err)
}
logHub := backup.NewLogHub()
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
recordService := NewBackupRecordService(records, execution, logHub)
return recordService, execution
}
// TestBackupRecordLock_BlocksDeletion 验证保留锁定后手动删除被拒绝,解锁后可删除。
func TestBackupRecordLock_BlocksDeletion(t *testing.T) {
recordService, execution := newLockTestHarness(t)
ctx := context.Background()
bd, err := execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if bd.Status != "success" {
t.Fatalf("backup not success: %s", bd.Status)
}
// 锁定。
detail, err := recordService.SetLock(ctx, bd.ID, true)
if err != nil {
t.Fatalf("SetLock(true): %v", err)
}
if !detail.Locked {
t.Fatal("expected detail.Locked = true")
}
// 锁定状态下删除应被拒绝。
if err := execution.DeleteRecord(ctx, bd.ID); err == nil {
t.Fatal("expected delete of locked record to be rejected")
} else if !strings.Contains(err.Error(), "保留锁定") {
t.Fatalf("unexpected delete error: %v", err)
}
// 记录仍然存在。
if got, _ := recordService.Get(ctx, bd.ID); got == nil {
t.Fatal("locked record must still exist after rejected delete")
}
// 解锁后可删除。
if _, err := recordService.SetLock(ctx, bd.ID, false); err != nil {
t.Fatalf("SetLock(false): %v", err)
}
if err := execution.DeleteRecord(ctx, bd.ID); err != nil {
t.Fatalf("delete after unlock should succeed: %v", err)
}
}

View File

@@ -36,13 +36,14 @@ type BackupRecordSummary struct {
ErrorMessage string `json:"errorMessage"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Locked bool `json:"locked"`
}
type BackupRecordDetail struct {
BackupRecordSummary
LogContent string `json:"logContent"`
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
}
type BackupRecordService struct {
@@ -102,6 +103,25 @@ func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
return s.execution.DeleteRecord(ctx, id)
}
// SetLock 设置或解除备份记录的保留锁定(法律保留)。
// 锁定后该记录免于保留期/数量自动清理,且禁止手动删除,直至显式解锁。
func (s *BackupRecordService) SetLock(ctx context.Context, id uint, locked bool) (*BackupRecordDetail, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
}
if item.Locked != locked {
item.Locked = locked
if err := s.records.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_RECORD_LOCK_FAILED", "无法更新备份锁定状态", err)
}
}
return toBackupRecordDetail(item, s.logHub), nil
}
func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
return BackupRecordSummary{
ID: item.ID,
@@ -118,6 +138,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
ErrorMessage: item.ErrorMessage,
StartedAt: item.StartedAt,
CompletedAt: item.CompletedAt,
Locked: item.Locked,
}
}