mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-06 07:56:41 +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 {
|
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)
|
selected := make(map[uint]model.BackupRecord)
|
||||||
if maxBackups > 0 && len(records) > maxBackups {
|
if maxBackups > 0 && len(records) > maxBackups {
|
||||||
for _, record := range records[maxBackups:] {
|
for _, record := range records[maxBackups:] {
|
||||||
@@ -113,3 +124,12 @@ func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxB
|
|||||||
}
|
}
|
||||||
return result
|
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) {
|
func TestCleanupDeletesExpiredRecords(t *testing.T) {
|
||||||
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||||
completedNew := now.Add(-24 * time.Hour)
|
completedNew := now.Add(-24 * time.Hour)
|
||||||
|
|||||||
@@ -161,6 +161,32 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"deleted": true})
|
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) {
|
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
|
||||||
var input struct {
|
var input struct {
|
||||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
||||||
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
||||||
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
||||||
|
backupRecords.PUT("/:id/lock", RequireNotViewer(), backupRecordHandler.SetLock)
|
||||||
|
|
||||||
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
|
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
|
||||||
// 创建恢复仍然走 POST /backup/records/:id/restore(以源备份记录为触发点)。
|
// 创建恢复仍然走 POST /backup/records/:id/restore(以源备份记录为触发点)。
|
||||||
|
|||||||
@@ -9,27 +9,30 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type BackupRecord struct {
|
type BackupRecord struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||||
Task BackupTask `json:"task,omitempty"`
|
Task BackupTask `json:"task,omitempty"`
|
||||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||||
// NodeID 执行该次备份的节点(0 = 本机 Master)。用于集群中识别 local_disk 类型
|
// NodeID 执行该次备份的节点(0 = 本机 Master)。用于集群中识别 local_disk 类型
|
||||||
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
|
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
|
||||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||||
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
||||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
|
||||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
|
||||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
func (BackupRecord) TableName() string {
|
||||||
|
|||||||
@@ -276,6 +276,10 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
|
|||||||
if record == nil {
|
if record == nil {
|
||||||
return apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
|
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 {
|
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
|
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,13 +36,14 @@ type BackupRecordSummary struct {
|
|||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackupRecordDetail struct {
|
type BackupRecordDetail struct {
|
||||||
BackupRecordSummary
|
BackupRecordSummary
|
||||||
LogContent string `json:"logContent"`
|
LogContent string `json:"logContent"`
|
||||||
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
|
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
|
||||||
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
|
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackupRecordService struct {
|
type BackupRecordService struct {
|
||||||
@@ -102,6 +103,25 @@ func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
|
|||||||
return s.execution.DeleteRecord(ctx, id)
|
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 {
|
func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
||||||
return BackupRecordSummary{
|
return BackupRecordSummary{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
@@ -118,6 +138,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
|||||||
ErrorMessage: item.ErrorMessage,
|
ErrorMessage: item.ErrorMessage,
|
||||||
StartedAt: item.StartedAt,
|
StartedAt: item.StartedAt,
|
||||||
CompletedAt: item.CompletedAt,
|
CompletedAt: item.CompletedAt,
|
||||||
|
Locked: item.Locked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button, Card, Empty, Message, Select, Space, Table, Tag, Typography } f
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { BackupRecordLogDrawer } from '../../components/backup-records/BackupRecordLogDrawer'
|
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 { listBackupTasks } from '../../services/backup-tasks'
|
||||||
import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records'
|
import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records'
|
||||||
import type { BackupTaskSummary } from '../../types/backup-tasks'
|
import type { BackupTaskSummary } from '../../types/backup-tasks'
|
||||||
@@ -33,6 +33,7 @@ export function BackupRecordsPage() {
|
|||||||
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
|
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [lockBusyId, setLockBusyId] = useState<number | null>(null)
|
||||||
|
|
||||||
const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined
|
const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined
|
||||||
const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined
|
const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined
|
||||||
@@ -64,6 +65,19 @@ export function BackupRecordsPage() {
|
|||||||
void loadData()
|
void loadData()
|
||||||
}, [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) {
|
function updateSearchParam(key: 'taskId' | 'status' | 'recordId', value?: string) {
|
||||||
const nextParams = new URLSearchParams(searchParams)
|
const nextParams = new URLSearchParams(searchParams)
|
||||||
if (!value || value === '0') {
|
if (!value || value === '0') {
|
||||||
@@ -96,7 +110,10 @@ export function BackupRecordsPage() {
|
|||||||
dataIndex: 'fileName',
|
dataIndex: 'fileName',
|
||||||
render: (_: unknown, record: BackupRecordSummary) => (
|
render: (_: unknown, record: BackupRecordSummary) => (
|
||||||
<Space direction="vertical" size={2}>
|
<Space direction="vertical" size={2}>
|
||||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
<Space size={4}>
|
||||||
|
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||||
|
{record.locked && <Tag color="orange" size="small" bordered>已锁定</Tag>}
|
||||||
|
</Space>
|
||||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||||
{record.checksum && (
|
{record.checksum && (
|
||||||
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
|
||||||
@@ -129,11 +146,24 @@ export function BackupRecordsPage() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
dataIndex: 'actions',
|
dataIndex: 'actions',
|
||||||
width: 120,
|
width: 180,
|
||||||
render: (_: unknown, record: BackupRecordSummary) => (
|
render: (_: unknown, record: BackupRecordSummary) => (
|
||||||
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
|
<Space size={4}>
|
||||||
查看日志
|
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
|
||||||
</Button>
|
查看日志
|
||||||
|
</Button>
|
||||||
|
{record.status === 'success' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
status={record.locked ? 'warning' : 'default'}
|
||||||
|
loading={lockBusyId === record.id}
|
||||||
|
onClick={() => void handleToggleLock(record)}
|
||||||
|
>
|
||||||
|
{record.locked ? '解锁' : '锁定'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ export async function deleteBackupRecord(id: number) {
|
|||||||
return unwrapApiEnvelope(response.data)
|
return unwrapApiEnvelope(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setBackupRecordLock 设置/解除备份记录的保留锁定(法律保留)。
|
||||||
|
export async function setBackupRecordLock(id: number, locked: boolean) {
|
||||||
|
const response = await http.put<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}/lock`, { locked })
|
||||||
|
return unwrapApiEnvelope(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
|
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface BackupRecordSummary {
|
|||||||
errorMessage: string
|
errorMessage: string
|
||||||
startedAt: string
|
startedAt: string
|
||||||
completedAt?: string
|
completedAt?: string
|
||||||
|
locked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageUploadResultItem {
|
export interface StorageUploadResultItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user