From 493e1faff5d30568442c76a051b260aad4a577a1 Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Wed, 27 May 2026 19:50:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E6=96=B0=E5=A2=9E=E6=8C=89?= =?UTF-8?q?=E9=9C=80=EF=BC=88=E9=80=89=E6=8B=A9=E6=80=A7=EF=BC=89=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=81=A2=E5=A4=8D=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。 --- server/internal/backup/file_runner.go | 33 +++++++++++++++ .../internal/backup/file_runner_diff_test.go | 41 +++++++++++++++++++ server/internal/backup/types.go | 2 + server/internal/http/backup_record_handler.go | 6 ++- server/internal/service/restore_service.go | 21 +++++++++- .../BackupRecordContentsModal.tsx | 22 ++++++++-- .../backup-records/BackupRecordLogDrawer.tsx | 27 +++++++++++- web/src/services/restore-records.ts | 7 ++-- 8 files changed, 149 insertions(+), 10 deletions(-) diff --git a/server/internal/backup/file_runner.go b/server/internal/backup/file_runner.go index 60d067e..37f6635 100644 --- a/server/internal/backup/file_runner.go +++ b/server/internal/backup/file_runner.go @@ -207,6 +207,10 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri if cleanName == "." || cleanName == "" { continue } + // 选择性恢复:仅提取被选中的文件/目录(及其子项)。 + if len(task.SelectedPaths) > 0 && !pathSelected(cleanName, task.SelectedPaths) { + continue + } targetPath, ok := resolveWithinParent(targetParent, cleanName) if !ok { return fmt.Errorf("tar entry escapes restore path") @@ -233,6 +237,10 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri } } } + // 选择性恢复时仅对选中范围应用删除,避免误删未选中的文件。 + if len(task.SelectedPaths) > 0 { + pendingDeletions = filterSelectedPaths(pendingDeletions, task.SelectedPaths) + } if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil { return err } @@ -240,6 +248,31 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri return nil } +// pathSelected 判断归档条目名是否落在选中集合内(精确匹配或位于选中目录之下)。 +func pathSelected(name string, selected []string) bool { + for _, sel := range selected { + clean := path.Clean(strings.TrimSpace(sel)) + if clean == "" || clean == "." { + continue + } + if name == clean || strings.HasPrefix(name, clean+"/") { + return true + } + } + return false +} + +// filterSelectedPaths 仅保留落在选中集合内的路径。 +func filterSelectedPaths(paths []string, selected []string) []string { + filtered := make([]string, 0, len(paths)) + for _, p := range paths { + if pathSelected(path.Clean(strings.TrimSpace(p)), selected) { + filtered = append(filtered, p) + } + } + return filtered +} + // resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径; // 越界(路径穿越)时返回 ok=false。提取与删除共用此校验,杜绝逃逸。 func resolveWithinParent(targetParent, name string) (string, bool) { diff --git a/server/internal/backup/file_runner_diff_test.go b/server/internal/backup/file_runner_diff_test.go index 86ca99d..ffb43d5 100644 --- a/server/internal/backup/file_runner_diff_test.go +++ b/server/internal/backup/file_runner_diff_test.go @@ -122,6 +122,47 @@ func TestFileRunnerDifferentialRoundTrip(t *testing.T) { diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) } +func TestPathSelected(t *testing.T) { + sel := []string{"src/a.txt", "src/sub"} + cases := map[string]bool{ + "src/a.txt": true, + "src/sub": true, + "src/sub/c.txt": true, // 选中目录下的子项 + "src/b.txt": false, // 未选中文件 + "src/subother": false, // 前缀相近但非子项,不应误判 + } + for name, want := range cases { + if got := pathSelected(name, sel); got != want { + t.Errorf("pathSelected(%q) = %v, want %v", name, got, want) + } + } +} + +// TestFileRunnerSelectiveRestore 验证按需恢复:仅选中的文件与目录被还原,未选中的文件不出现。 +func TestFileRunnerSelectiveRestore(t *testing.T) { + work := t.TempDir() + src := filepath.Join(work, "src") + diffWrite(t, filepath.Join(src, "a.txt"), "alpha") + diffWrite(t, filepath.Join(src, "b.txt"), "bravo") + diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie") + + runner := NewFileRunner() + full, err := runner.Run(context.Background(), TaskSpec{Name: "sel", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{}) + if err != nil { + t.Fatalf("full Run: %v", err) + } + + restoreRoot := t.TempDir() + restoreSrc := filepath.Join(restoreRoot, "src") + task := TaskSpec{Name: "sel", Type: "file", SourcePath: restoreSrc, SelectedPaths: []string{"src/a.txt", "src/sub"}} + if err := runner.Restore(context.Background(), task, full.ArtifactPath, NopLogWriter{}); err != nil { + t.Fatalf("selective Restore: %v", err) + } + diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha") + diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复 + diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复 +} + // TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。 func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) { src := filepath.Join(t.TempDir(), "src") diff --git a/server/internal/backup/types.go b/server/internal/backup/types.go index a0efe93..14a27f5 100644 --- a/server/internal/backup/types.go +++ b/server/internal/backup/types.go @@ -40,6 +40,8 @@ type TaskSpec struct { // 并记录被删除的路径。仅文件类型任务支持;BaseManifest 为空时回退为全量。 Differential bool BaseManifest Manifest + // SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。 + SelectedPaths []string } type RunResult struct { diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index 9087482..44852c1 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -151,7 +151,11 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) { if subject, exists := c.Get(contextUserSubjectKey); exists { triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject)) } - detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy) + var body struct { + SelectedPaths []string `json:"selectedPaths"` + } + _ = c.ShouldBindJSON(&body) // body 可选:无 body 为整体恢复,含 selectedPaths 为按需恢复 + detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, triggeredBy) if err != nil { response.Error(c, err) return diff --git a/server/internal/service/restore_service.go b/server/internal/service/restore_service.go index be618c0..7d50cd8 100644 --- a/server/internal/service/restore_service.go +++ b/server/internal/service/restore_service.go @@ -120,6 +120,11 @@ type RestoreRecordDetail struct { // 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running) // 若本地:异步执行并立即返回。 func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, triggeredBy string) (*RestoreRecordDetail, error) { + return s.StartSelective(ctx, backupRecordID, nil, triggeredBy) +} + +// StartSelective 启动恢复;selectedPaths 非空时仅恢复选中的文件/目录(按需恢复,仅本机文件备份)。 +func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, triggeredBy string) (*RestoreRecordDetail, error) { record, err := s.records.FindByID(ctx, backupRecordID) if err != nil { return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err) @@ -137,6 +142,14 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger if task == nil { return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在", fmt.Errorf("backup task %d not found", record.TaskID)) } + if len(selectedPaths) > 0 { + if task.Type != model.BackupTaskTypeFile { + return nil, apperror.BadRequest("RESTORE_SELECTIVE_UNSUPPORTED", "按需(选择性)恢复仅支持文件类型备份", nil) + } + if s.resolveRemoteNode(ctx, s.resolveRestoreNodeID(record, task)) != nil { + return nil, apperror.BadRequest("RESTORE_SELECTIVE_REMOTE_UNSUPPORTED", "按需恢复当前仅支持本机 Master 执行", nil) + } + } startedAt := s.now() restoreNodeID := s.resolveRestoreNodeID(record, task) @@ -178,7 +191,7 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger // 本地节点:异步执行 run := func() { - s.executeLocally(context.Background(), restore.ID, task, record) + s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths) } s.async(run) return s.getDetail(ctx, restore.ID) @@ -205,7 +218,7 @@ func (s *RestoreService) resolveRemoteNode(ctx context.Context, nodeID uint) *mo } // executeLocally 在 Master 本地执行恢复。 -func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord) { +func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord, selectedPaths []string) { s.semaphore <- struct{}{} defer func() { <-s.semaphore }() @@ -230,6 +243,10 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas logger.Errorf("构建恢复规格失败:%v", specErr) return } + if len(selectedPaths) > 0 { + spec.SelectedPaths = selectedPaths + logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths)) + } runner, runnerErr := s.runnerRegistry.Runner(spec.Type) if runnerErr != nil { errMessage = runnerErr.Error() diff --git a/web/src/components/backup-records/BackupRecordContentsModal.tsx b/web/src/components/backup-records/BackupRecordContentsModal.tsx index 0520579..7042f67 100644 --- a/web/src/components/backup-records/BackupRecordContentsModal.tsx +++ b/web/src/components/backup-records/BackupRecordContentsModal.tsx @@ -1,4 +1,4 @@ -import { Alert, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react' +import { Alert, Button, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' import { getBackupRecordContents } from '../../services/backup-records' import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records' @@ -9,15 +9,18 @@ interface BackupRecordContentsModalProps { visible: boolean recordId?: number onClose: () => void + // onRestoreSelected 提供时启用按需恢复:勾选条目后回调选中的归档路径。 + onRestoreSelected?: (paths: string[]) => void } // BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。 // 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。 -export function BackupRecordContentsModal({ visible, recordId, onClose }: BackupRecordContentsModalProps) { +export function BackupRecordContentsModal({ visible, recordId, onClose, onRestoreSelected }: BackupRecordContentsModalProps) { const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [contents, setContents] = useState(null) const [keyword, setKeyword] = useState('') + const [selectedKeys, setSelectedKeys] = useState([]) useEffect(() => { if (!visible || !recordId) { @@ -28,6 +31,7 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup setError('') setKeyword('') setContents(null) + setSelectedKeys([]) void (async () => { try { const data = await getBackupRecordContents(recordId) @@ -71,11 +75,23 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup {contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''} {contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''} - +
+ + {onRestoreSelected && ( + + )} +
setSelectedKeys(keys as string[]) } + : undefined + } pagination={{ pageSize: 50, sizeCanChange: false }} scroll={{ y: 420 }} columns={[ diff --git a/web/src/components/backup-records/BackupRecordLogDrawer.tsx b/web/src/components/backup-records/BackupRecordLogDrawer.tsx index b0c370f..d41c939 100644 --- a/web/src/components/backup-records/BackupRecordLogDrawer.tsx +++ b/web/src/components/backup-records/BackupRecordLogDrawer.tsx @@ -213,6 +213,26 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } } } + // handleSelectiveRestore 按需恢复:仅还原内容浏览中勾选的文件/目录到原位置。 + async function handleSelectiveRestore(paths: string[]) { + if (!recordId || paths.length === 0) { + return + } + if (!window.confirm(`确定将选中的 ${paths.length} 项恢复到原位置吗?这会覆盖目标位置的现有文件,不可撤销。`)) { + return + } + try { + const restore = await startRestoreFromBackup(recordId, paths) + Message.success('按需恢复已启动,正在打开日志') + setContentsVisible(false) + await onChanged?.() + navigate(`/restore/records?restoreId=${restore.id}`) + onCancel() + } catch (restoreError) { + Message.error(resolveErrorMessage(restoreError, '启动按需恢复失败')) + } + } + async function handleDelete() { if (!recordId) { return @@ -327,7 +347,12 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } }} onConfirm={() => void handleConfirmRestore()} /> - setContentsVisible(false)} /> + setContentsVisible(false)} + onRestoreSelected={writable && record?.status === 'success' ? (paths) => void handleSelectiveRestore(paths) : undefined} + /> ) } diff --git a/web/src/services/restore-records.ts b/web/src/services/restore-records.ts index ae79889..c84c1f9 100644 --- a/web/src/services/restore-records.ts +++ b/web/src/services/restore-records.ts @@ -39,9 +39,10 @@ export async function getRestoreRecord(id: number) { return unwrapApiEnvelope(response.data) } -// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。 -export async function startRestoreFromBackup(backupRecordId: number) { - const response = await http.post>(`/backup/records/${backupRecordId}/restore`) +// startRestoreFromBackup 通过源备份记录启动恢复。selectedPaths 非空时为按需(选择性)恢复。 +export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[]) { + const body = selectedPaths && selectedPaths.length > 0 ? { selectedPaths } : undefined + const response = await http.post>(`/backup/records/${backupRecordId}/restore`, body) return unwrapApiEnvelope(response.data) }