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
33 changes: 33 additions & 0 deletions server/internal/backup/file_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -233,13 +237,42 @@ 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
}
writer.WriteLine("文件恢复完成")
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) {
Expand Down
41 changes: 41 additions & 0 deletions server/internal/backup/file_runner_diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions server/internal/backup/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type TaskSpec struct {
// 并记录被删除的路径。仅文件类型任务支持;BaseManifest 为空时回退为全量。
Differential bool
BaseManifest Manifest
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
SelectedPaths []string
}

type RunResult struct {
Expand Down
6 changes: 5 additions & 1 deletion server/internal/http/backup_record_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions server/internal/service/restore_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 }()

Expand All @@ -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()
Expand Down
22 changes: 19 additions & 3 deletions web/src/components/backup-records/BackupRecordContentsModal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<BackupRecordContents | null>(null)
const [keyword, setKeyword] = useState('')
const [selectedKeys, setSelectedKeys] = useState<string[]>([])

useEffect(() => {
if (!visible || !recordId) {
Expand All @@ -28,6 +31,7 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup
setError('')
setKeyword('')
setContents(null)
setSelectedKeys([])
void (async () => {
try {
const data = await getBackupRecordContents(recordId)
Expand Down Expand Up @@ -71,11 +75,23 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
</Typography.Text>
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ margin: '8px 0' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ flex: 1 }} />
{onRestoreSelected && (
<Button type="primary" status="warning" disabled={selectedKeys.length === 0} onClick={() => onRestoreSelected(selectedKeys)}>
恢复选中({selectedKeys.length})
</Button>
)}
</div>
<Table
size="small"
rowKey="path"
data={filtered}
rowSelection={
onRestoreSelected
? { type: 'checkbox', selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys as string[]) }
: undefined
}
pagination={{ pageSize: 50, sizeCanChange: false }}
scroll={{ y: 420 }}
columns={[
Expand Down
27 changes: 26 additions & 1 deletion web/src/components/backup-records/BackupRecordLogDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -327,7 +347,12 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
}}
onConfirm={() => void handleConfirmRestore()}
/>
<BackupRecordContentsModal visible={contentsVisible} recordId={recordId} onClose={() => setContentsVisible(false)} />
<BackupRecordContentsModal
visible={contentsVisible}
recordId={recordId}
onClose={() => setContentsVisible(false)}
onRestoreSelected={writable && record?.status === 'success' ? (paths) => void handleSelectiveRestore(paths) : undefined}
/>
</Drawer>
)
}
7 changes: 4 additions & 3 deletions web/src/services/restore-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiEnvelope<RestoreRecordDetail>>(`/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<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
return unwrapApiEnvelope(response.data)
}

Expand Down
Loading