From 50ce6587d85993f7bbf25f28d3a30916cec742ad Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Mon, 1 Jun 2026 00:39:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(restore):=20=E6=81=A2=E5=A4=8D=E5=88=B0?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E7=9B=AE=E5=BD=95=EF=BC=88=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=9C=AC=E6=9C=BA=E6=81=A2=E5=A4=8D=20+=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=BC=B9=E7=AA=97=E8=BE=93=E5=85=A5=EF=BC=89?= =?UTF-8?q?=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(restore): 支持恢复到指定目录(文件类型本机恢复) 恢复此前只能覆盖原始源路径。新增「恢复到指定目录」:把文件备份还原到任意目录, 用于测试恢复、迁移、并排恢复而不覆盖现网数据。 - backup.TaskSpec +RestoreTargetPath;FileRunner.Restore 非空时把归档解压到该目录。 - model.RestoreRecord +TargetPath(持久化/审计)。 - RestoreService.Start 增加 targetPath 参数与校验:仅文件类型、需绝对路径、 远程节点暂不支持(清晰报错);executeLocally 透传到 spec。 - 恢复触发端点接受可选请求体 {targetPath}(无 body 时恢复到原始路径)。 - 测试:恢复到指定目录后文件落在该目录;相对路径被拒。 * feat(restore): 恢复确认弹窗支持指定恢复目录 文件类型 + 本机恢复时,恢复确认弹窗新增「恢复到指定目录」输入(可选、绝对路径、 留空=原位置),并实时反映在「恢复目标」摘要中;经 startRestoreFromBackup 透传 targetPath。 --- server/internal/backup/file_runner.go | 5 ++ server/internal/backup/types.go | 3 + server/internal/http/backup_record_handler.go | 7 ++- server/internal/model/restore_record.go | 32 ++++++----- server/internal/service/restore_service.go | 34 ++++++++++-- .../internal/service/restore_service_test.go | 55 +++++++++++++++++++ .../backup-records/BackupRecordLogDrawer.tsx | 6 +- .../restore-records/RestoreConfirmModal.tsx | 31 +++++++++-- web/src/services/restore-records.ts | 15 ++++- 9 files changed, 155 insertions(+), 33 deletions(-) diff --git a/server/internal/backup/file_runner.go b/server/internal/backup/file_runner.go index 274178a..b86e20f 100644 --- a/server/internal/backup/file_runner.go +++ b/server/internal/backup/file_runner.go @@ -185,6 +185,11 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri restoreSource = task.SourcePaths[0] } targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource))) + // 恢复到指定位置:非空时归档解压到用户指定目录,而非原始源父目录。 + if override := strings.TrimSpace(task.RestoreTargetPath); override != "" { + targetParent = filepath.Clean(override) + writer.WriteLine(fmt.Sprintf("恢复到指定目录:%s", targetParent)) + } if err := os.MkdirAll(targetParent, 0o755); err != nil { return fmt.Errorf("create restore parent: %w", err) } diff --git a/server/internal/backup/types.go b/server/internal/backup/types.go index 14a27f5..5b496a4 100644 --- a/server/internal/backup/types.go +++ b/server/internal/backup/types.go @@ -42,6 +42,9 @@ type TaskSpec struct { BaseManifest Manifest // SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。 SelectedPaths []string + // RestoreTargetPath 仅用于恢复:非空时,文件类型恢复将归档解压到该目录, + // 而非默认的原始源路径父目录。用于「恢复到指定位置」(迁移/测试/并排恢复)。 + RestoreTargetPath string } type RunResult struct { diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index 44852c1..0a9065b 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -151,11 +151,14 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) { if subject, exists := c.Get(contextUserSubjectKey); exists { triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject)) } + // 可选请求体:selectedPaths 按需(选择性)恢复;targetPath 恢复到指定目录(仅文件类型本机恢复)。 + // 无 body 时为整体恢复到原始路径。 var body struct { SelectedPaths []string `json:"selectedPaths"` + TargetPath string `json:"targetPath"` } - _ = c.ShouldBindJSON(&body) // body 可选:无 body 为整体恢复,含 selectedPaths 为按需恢复 - detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, triggeredBy) + _ = c.ShouldBindJSON(&body) + detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, strings.TrimSpace(body.TargetPath), triggeredBy) if err != nil { response.Error(c, err) return diff --git a/server/internal/model/restore_record.go b/server/internal/model/restore_record.go index beec941..46e8cee 100644 --- a/server/internal/model/restore_record.go +++ b/server/internal/model/restore_record.go @@ -11,21 +11,23 @@ const ( ) type RestoreRecord struct { - ID uint `gorm:"primaryKey" json:"id"` - BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"` - BackupRecord BackupRecord `json:"backupRecord,omitempty"` - TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"` - Task BackupTask `json:"task,omitempty"` - NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"` - Status string `gorm:"size:20;index;not null" json:"status"` - ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"` - LogContent string `gorm:"column:log_content;type:text" json:"logContent"` - DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"` - StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"` - CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"` - TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"` + BackupRecord BackupRecord `json:"backupRecord,omitempty"` + TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"` + Task BackupTask `json:"task,omitempty"` + NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"` + // TargetPath 恢复到指定目录(仅文件类型本机恢复);空 = 恢复到原始源路径。 + TargetPath string `gorm:"column:target_path;size:500" json:"targetPath"` + Status string `gorm:"size:20;index;not null" json:"status"` + ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"` + LogContent string `gorm:"column:log_content;type:text" json:"logContent"` + DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"` + StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"` + CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"` + TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (RestoreRecord) TableName() string { diff --git a/server/internal/service/restore_service.go b/server/internal/service/restore_service.go index 7d50cd8..ba1c0db 100644 --- a/server/internal/service/restore_service.go +++ b/server/internal/service/restore_service.go @@ -120,11 +120,13 @@ 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) + return s.StartSelective(ctx, backupRecordID, nil, "", triggeredBy) } -// StartSelective 启动恢复;selectedPaths 非空时仅恢复选中的文件/目录(按需恢复,仅本机文件备份)。 -func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, triggeredBy string) (*RestoreRecordDetail, error) { +// StartSelective 启动恢复。两个可选项均仅适用于本机文件备份: +// - selectedPaths 非空时仅恢复选中的文件/目录(及其子项),用于按需(选择性)恢复; +// - targetPath 非空时把归档恢复到该绝对目录而非原始源路径父目录(迁移/测试/并排恢复)。 +func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, targetPath string, triggeredBy string) (*RestoreRecordDetail, error) { record, err := s.records.FindByID(ctx, backupRecordID) if err != nil { return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err) @@ -153,10 +155,26 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint startedAt := s.now() restoreNodeID := s.resolveRestoreNodeID(record, task) + + // 恢复到指定目录:仅文件类型 + 本机执行支持;需为绝对路径。 + targetPath = strings.TrimSpace(targetPath) + if targetPath != "" { + if task.Type != model.BackupTaskTypeFile { + return nil, apperror.BadRequest("RESTORE_TARGET_UNSUPPORTED", "仅文件类型备份支持恢复到指定目录", nil) + } + if !filepath.IsAbs(targetPath) { + return nil, apperror.BadRequest("RESTORE_TARGET_INVALID", "恢复目录必须是绝对路径", nil) + } + if s.isRemoteNode(ctx, restoreNodeID) { + return nil, apperror.BadRequest("RESTORE_TARGET_REMOTE_UNSUPPORTED", "远程节点恢复暂不支持指定目录,请在该节点本地操作", nil) + } + } + restore := &model.RestoreRecord{ BackupRecordID: backupRecordID, TaskID: record.TaskID, NodeID: restoreNodeID, + TargetPath: targetPath, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt, TriggeredBy: strings.TrimSpace(triggeredBy), @@ -191,7 +209,7 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint // 本地节点:异步执行 run := func() { - s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths) + s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths, targetPath) } s.async(run) return s.getDetail(ctx, restore.ID) @@ -218,7 +236,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, selectedPaths []string) { +func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord, selectedPaths []string, targetPath string) { s.semaphore <- struct{}{} defer func() { <-s.semaphore }() @@ -247,6 +265,12 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas spec.SelectedPaths = selectedPaths logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths)) } + // 恢复到指定目录(已在 StartSelective 校验为文件类型+绝对路径+本机); + // 应用于恢复链中的每个归档(全量铺底与差异覆盖均落到该目录)。 + if targetPath != "" { + spec.RestoreTargetPath = targetPath + logger.Infof("恢复到指定目录:%s", targetPath) + } runner, runnerErr := s.runnerRegistry.Runner(spec.Type) if runnerErr != nil { errMessage = runnerErr.Error() diff --git a/server/internal/service/restore_service_test.go b/server/internal/service/restore_service_test.go index 733fc3f..5991c9c 100644 --- a/server/internal/service/restore_service_test.go +++ b/server/internal/service/restore_service_test.go @@ -269,6 +269,61 @@ func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) { } } +// TestRestoreServiceStart_RestoresToAlternatePath 验证恢复到指定目录:归档落在指定目录, +// 且相对路径被拒绝。 +func TestRestoreServiceStart_RestoresToAlternatePath(t *testing.T) { + h := newRestoreTestHarness(t, false) + ctx := context.Background() + + backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1) + if err != nil { + t.Fatalf("RunTaskByIDSync: %v", err) + } + if backupDetail.Status != "success" { + t.Fatalf("expected backup success, got %s", backupDetail.Status) + } + + altDir := filepath.Join(t.TempDir(), "restore-here") + + // 相对路径应被拒绝(且不创建恢复记录)。 + if _, relErr := h.service.StartSelective(ctx, backupDetail.ID, nil, "relative/path", "tester"); relErr == nil { + t.Fatal("relative target path should be rejected") + } + + done := make(chan struct{}) + h.service.async = func(job func()) { + go func() { + job() + close(done) + }() + } + detail, err := h.service.StartSelective(ctx, backupDetail.ID, nil, altDir, "tester") + if err != nil { + t.Fatalf("StartSelective(altDir): %v", err) + } + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("restore did not complete in time") + } + + final, err := h.service.Get(ctx, detail.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if final.Status != model.RestoreRecordStatusSuccess { + t.Fatalf("expected success, got %s (err=%s)", final.Status, final.ErrorMessage) + } + // 源目录 basename 为 "source",归档解压到 altDir/source/index.html。 + got, err := os.ReadFile(filepath.Join(altDir, "source", "index.html")) + if err != nil { + t.Fatalf("read restored file at alternate path: %v", err) + } + if string(got) != "hello-restore" { + t.Fatalf("unexpected restored content at alt path: %q", string(got)) + } +} + func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) { h := newRestoreTestHarness(t, true) ctx := context.Background() diff --git a/web/src/components/backup-records/BackupRecordLogDrawer.tsx b/web/src/components/backup-records/BackupRecordLogDrawer.tsx index d41c939..b0e35c6 100644 --- a/web/src/components/backup-records/BackupRecordLogDrawer.tsx +++ b/web/src/components/backup-records/BackupRecordLogDrawer.tsx @@ -193,13 +193,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } } } - async function handleConfirmRestore() { + async function handleConfirmRestore(targetPath?: string) { if (!recordId) { return } setRestoreLoading(true) try { - const restore = await startRestoreFromBackup(recordId) + const restore = await startRestoreFromBackup(recordId, undefined, targetPath) Message.success('恢复已启动,正在打开日志') setRestoreModalVisible(false) setRestoreTask(null) @@ -345,7 +345,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } setRestoreModalVisible(false) setRestoreTask(null) }} - onConfirm={() => void handleConfirmRestore()} + onConfirm={(targetPath) => void handleConfirmRestore(targetPath)} /> void - onConfirm: () => void + onConfirm: (targetPath?: string) => void } // RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。 // 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。 +// 文件类型 + 本机恢复时,允许指定「恢复到其他目录」以避免覆盖原位置。 export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) { + const [targetPath, setTargetPath] = useState('') + if (!backupRecord || !task) { return ( - + onConfirm()} confirmLoading={loading} unmountOnExit> ) } const restoreTarget = renderRestoreTarget(task) + const isLocal = !task.nodeId || task.nodeId === 0 + const allowAltPath = task.type === 'file' && isLocal const nodeLabel = task.nodeId && task.nodeId > 0 ? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`) : '本机 Master' @@ -35,8 +41,9 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa cancelText="取消" okButtonProps={{ status: 'danger', loading }} onCancel={onCancel} - onOk={onConfirm} + onOk={() => onConfirm(targetPath.trim() || undefined)} unmountOnExit + afterClose={() => setTargetPath('')} > {task.type.toUpperCase()} }, { label: '执行节点', value: nodeLabel }, { label: '源备份', value: backupRecord.fileName || '-' }, - { label: '恢复目标', value: restoreTarget }, + { label: '恢复目标', value: targetPath.trim() ? {targetPath.trim()} : restoreTarget }, ]} /> + {allowAltPath && ( +
+ + 恢复到指定目录(可选,绝对路径;留空则恢复到原始位置) + + setTargetPath(value)} + /> +
+ )}
) diff --git a/web/src/services/restore-records.ts b/web/src/services/restore-records.ts index c84c1f9..ba5801d 100644 --- a/web/src/services/restore-records.ts +++ b/web/src/services/restore-records.ts @@ -39,9 +39,18 @@ export async function getRestoreRecord(id: number) { return unwrapApiEnvelope(response.data) } -// startRestoreFromBackup 通过源备份记录启动恢复。selectedPaths 非空时为按需(选择性)恢复。 -export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[]) { - const body = selectedPaths && selectedPaths.length > 0 ? { selectedPaths } : undefined +// startRestoreFromBackup 通过源备份记录启动恢复。两个可选项互不影响: +// - selectedPaths 非空时为按需(选择性)恢复,仅还原选中的文件/目录; +// - targetPath 非空时把文件归档恢复到该绝对目录而非原始路径(仅文件类型本机恢复)。 +// 返回新建的恢复记录详情。 +export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[], targetPath?: string) { + const body: { selectedPaths?: string[]; targetPath?: string } = {} + if (selectedPaths && selectedPaths.length > 0) { + body.selectedPaths = selectedPaths + } + if (targetPath && targetPath.trim()) { + body.targetPath = targetPath.trim() + } const response = await http.post>(`/backup/records/${backupRecordId}/restore`, body) return unwrapApiEnvelope(response.data) }