Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server/internal/backup/file_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,12 @@
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 {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return fmt.Errorf("create restore parent: %w", err)
}
var pendingDeletions []string
Expand Down Expand Up @@ -223,14 +228,14 @@
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return fmt.Errorf("create restore dir: %w", err)
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return fmt.Errorf("create restore parent dir: %w", err)
}
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if err != nil {
return fmt.Errorf("create restore file: %w", err)
}
Expand Down Expand Up @@ -321,7 +326,7 @@
if !ok {
return fmt.Errorf("deletion entry escapes restore path")
}
if err := os.RemoveAll(targetPath); err != nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return fmt.Errorf("apply deletion %s: %w", clean, err)
}
}
Expand Down
3 changes: 3 additions & 0 deletions server/internal/backup/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type TaskSpec struct {
BaseManifest Manifest
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
SelectedPaths []string
// RestoreTargetPath 仅用于恢复:非空时,文件类型恢复将归档解压到该目录,
// 而非默认的原始源路径父目录。用于「恢复到指定位置」(迁移/测试/并排恢复)。
RestoreTargetPath string
}

type RunResult struct {
Expand Down
7 changes: 5 additions & 2 deletions server/internal/http/backup_record_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 17 additions & 15 deletions server/internal/model/restore_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 29 additions & 5 deletions server/internal/service/restore_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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 }()

Expand Down Expand Up @@ -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()
Expand Down
55 changes: 55 additions & 0 deletions server/internal/service/restore_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/backup-records/BackupRecordLogDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -345,7 +345,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
setRestoreModalVisible(false)
setRestoreTask(null)
}}
onConfirm={() => void handleConfirmRestore()}
onConfirm={(targetPath) => void handleConfirmRestore(targetPath)}
/>
<BackupRecordContentsModal
visible={contentsVisible}
Expand Down
31 changes: 26 additions & 5 deletions web/src/components/restore-records/RestoreConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Alert, Descriptions, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import { Alert, Descriptions, Input, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import { useState } from 'react'
import type { BackupRecordDetail } from '../../types/backup-records'
import type { BackupTaskDetail } from '../../types/backup-tasks'

Expand All @@ -8,21 +9,26 @@ interface RestoreConfirmModalProps {
backupRecord: BackupRecordDetail | null
task: BackupTaskDetail | null
onCancel: () => 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 (
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={onConfirm} confirmLoading={loading} unmountOnExit>
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={() => onConfirm()} confirmLoading={loading} unmountOnExit>
<Alert type="info" content="正在加载任务与备份信息..." />
</Modal>
)
}

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'
Expand All @@ -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('')}
>
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert
Expand All @@ -51,9 +58,23 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
{ label: '类型', value: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
{ label: '执行节点', value: nodeLabel },
{ label: '源备份', value: backupRecord.fileName || '-' },
{ label: '恢复目标', value: restoreTarget },
{ label: '恢复目标', value: targetPath.trim() ? <Typography.Text code>{targetPath.trim()}</Typography.Text> : restoreTarget },
]}
/>
{allowAltPath && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
恢复到指定目录(可选,绝对路径;留空则恢复到原始位置)
</Typography.Text>
<Input
style={{ marginTop: 4 }}
allowClear
value={targetPath}
placeholder="/path/to/restore-here"
onChange={(value) => setTargetPath(value)}
/>
</div>
)}
</Space>
</Modal>
)
Expand Down
15 changes: 12 additions & 3 deletions web/src/services/restore-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
return unwrapApiEnvelope(response.data)
}
Expand Down
Loading