diff --git a/server/internal/backup/retention/service.go b/server/internal/backup/retention/service.go index b9393f2..cee8b6b 100644 --- a/server/internal/backup/retention/service.go +++ b/server/internal/backup/retention/service.go @@ -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 +} diff --git a/server/internal/backup/retention/service_test.go b/server/internal/backup/retention/service_test.go index 2c88af6..8661ddf 100644 --- a/server/internal/backup/retention/service_test.go +++ b/server/internal/backup/retention/service_test.go @@ -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) diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index 4d9b733..fd7a055 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -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"` diff --git a/server/internal/http/router.go b/server/internal/http/router.go index d35447a..6745ebd 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -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(以源备份记录为触发点)。 diff --git a/server/internal/model/backup_record.go b/server/internal/model/backup_record.go index 20fd493..ba31ffc 100644 --- a/server/internal/model/backup_record.go +++ b/server/internal/model/backup_record.go @@ -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 { diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go index 71f25fc..27a6607 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -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) != "" { diff --git a/server/internal/service/backup_record_lock_test.go b/server/internal/service/backup_record_lock_test.go new file mode 100644 index 0000000..3a0799f --- /dev/null +++ b/server/internal/service/backup_record_lock_test.go @@ -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) + } +} diff --git a/server/internal/service/backup_record_service.go b/server/internal/service/backup_record_service.go index d5ae215..e509fd8 100644 --- a/server/internal/service/backup_record_service.go +++ b/server/internal/service/backup_record_service.go @@ -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, } } diff --git a/web/src/pages/backup-records/BackupRecordsPage.tsx b/web/src/pages/backup-records/BackupRecordsPage.tsx index 2543dcc..540df30 100644 --- a/web/src/pages/backup-records/BackupRecordsPage.tsx +++ b/web/src/pages/backup-records/BackupRecordsPage.tsx @@ -2,7 +2,7 @@ import { Button, Card, Empty, Message, Select, Space, Table, Tag, Typography } f import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { BackupRecordLogDrawer } from '../../components/backup-records/BackupRecordLogDrawer' -import { listBackupRecords } from '../../services/backup-records' +import { listBackupRecords, setBackupRecordLock } from '../../services/backup-records' import { listBackupTasks } from '../../services/backup-tasks' import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records' import type { BackupTaskSummary } from '../../types/backup-tasks' @@ -33,6 +33,7 @@ export function BackupRecordsPage() { const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [lockBusyId, setLockBusyId] = useState(null) const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined @@ -64,6 +65,19 @@ export function BackupRecordsPage() { void loadData() }, [loadData]) + async function handleToggleLock(record: BackupRecordSummary) { + setLockBusyId(record.id) + try { + await setBackupRecordLock(record.id, !record.locked) + Message.success(record.locked ? '已解除保留锁定' : '已锁定:该备份将豁免保留清理与删除') + await loadData() + } catch (e) { + Message.error(resolveErrorMessage(e, '操作失败')) + } finally { + setLockBusyId(null) + } + } + function updateSearchParam(key: 'taskId' | 'status' | 'recordId', value?: string) { const nextParams = new URLSearchParams(searchParams) if (!value || value === '0') { @@ -96,7 +110,10 @@ export function BackupRecordsPage() { dataIndex: 'fileName', render: (_: unknown, record: BackupRecordSummary) => ( - {record.fileName || '-'} + + {record.fileName || '-'} + {record.locked && 已锁定} + {formatBytes(record.fileSize)} {record.checksum && ( @@ -129,11 +146,24 @@ export function BackupRecordsPage() { { title: '操作', dataIndex: 'actions', - width: 120, + width: 180, render: (_: unknown, record: BackupRecordSummary) => ( - + + + {record.status === 'success' && ( + + )} + ), }, ] diff --git a/web/src/services/backup-records.ts b/web/src/services/backup-records.ts index b158564..af36fbf 100644 --- a/web/src/services/backup-records.ts +++ b/web/src/services/backup-records.ts @@ -89,6 +89,12 @@ export async function deleteBackupRecord(id: number) { return unwrapApiEnvelope(response.data) } +// setBackupRecordLock 设置/解除备份记录的保留锁定(法律保留)。 +export async function setBackupRecordLock(id: number, locked: boolean) { + const response = await http.put>(`/backup/records/${id}/lock`, { locked }) + return unwrapApiEnvelope(response.data) +} + export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) { const controller = new AbortController() diff --git a/web/src/types/backup-records.ts b/web/src/types/backup-records.ts index 2a59dc0..d3e9185 100644 --- a/web/src/types/backup-records.ts +++ b/web/src/types/backup-records.ts @@ -25,6 +25,7 @@ export interface BackupRecordSummary { errorMessage: string startedAt: string completedAt?: string + locked: boolean } export interface StorageUploadResultItem {