mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-05 07:26:43 +08:00
feat(backup): 新增按需(选择性)文件恢复 (#91)
在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。
This commit is contained in:
@@ -207,6 +207,10 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
|||||||
if cleanName == "." || cleanName == "" {
|
if cleanName == "." || cleanName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 选择性恢复:仅提取被选中的文件/目录(及其子项)。
|
||||||
|
if len(task.SelectedPaths) > 0 && !pathSelected(cleanName, task.SelectedPaths) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
targetPath, ok := resolveWithinParent(targetParent, cleanName)
|
targetPath, ok := resolveWithinParent(targetParent, cleanName)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("tar entry escapes restore path")
|
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 {
|
if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -240,6 +248,31 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
|||||||
return nil
|
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 下的绝对路径;
|
// resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径;
|
||||||
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验,杜绝逃逸。
|
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验,杜绝逃逸。
|
||||||
func resolveWithinParent(targetParent, name string) (string, bool) {
|
func resolveWithinParent(targetParent, name string) (string, bool) {
|
||||||
|
|||||||
@@ -122,6 +122,47 @@ func TestFileRunnerDifferentialRoundTrip(t *testing.T) {
|
|||||||
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt"))
|
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 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
|
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
|
||||||
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
|
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
|
||||||
src := filepath.Join(t.TempDir(), "src")
|
src := filepath.Join(t.TempDir(), "src")
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ type TaskSpec struct {
|
|||||||
// 并记录被删除的路径。仅文件类型任务支持;BaseManifest 为空时回退为全量。
|
// 并记录被删除的路径。仅文件类型任务支持;BaseManifest 为空时回退为全量。
|
||||||
Differential bool
|
Differential bool
|
||||||
BaseManifest Manifest
|
BaseManifest Manifest
|
||||||
|
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
|
||||||
|
SelectedPaths []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunResult struct {
|
type RunResult struct {
|
||||||
|
|||||||
@@ -151,7 +151,11 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
|||||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
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 {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ type RestoreRecordDetail struct {
|
|||||||
// 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running)
|
// 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running)
|
||||||
// 若本地:异步执行并立即返回。
|
// 若本地:异步执行并立即返回。
|
||||||
func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, triggeredBy string) (*RestoreRecordDetail, error) {
|
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)
|
record, err := s.records.FindByID(ctx, backupRecordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
|
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 {
|
if task == nil {
|
||||||
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在", fmt.Errorf("backup task %d not found", record.TaskID))
|
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()
|
startedAt := s.now()
|
||||||
restoreNodeID := s.resolveRestoreNodeID(record, task)
|
restoreNodeID := s.resolveRestoreNodeID(record, task)
|
||||||
@@ -178,7 +191,7 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
|
|||||||
|
|
||||||
// 本地节点:异步执行
|
// 本地节点:异步执行
|
||||||
run := func() {
|
run := func() {
|
||||||
s.executeLocally(context.Background(), restore.ID, task, record)
|
s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths)
|
||||||
}
|
}
|
||||||
s.async(run)
|
s.async(run)
|
||||||
return s.getDetail(ctx, restore.ID)
|
return s.getDetail(ctx, restore.ID)
|
||||||
@@ -205,7 +218,7 @@ func (s *RestoreService) resolveRemoteNode(ctx context.Context, nodeID uint) *mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeLocally 在 Master 本地执行恢复。
|
// 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{}{}
|
s.semaphore <- struct{}{}
|
||||||
defer func() { <-s.semaphore }()
|
defer func() { <-s.semaphore }()
|
||||||
|
|
||||||
@@ -230,6 +243,10 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
|
|||||||
logger.Errorf("构建恢复规格失败:%v", specErr)
|
logger.Errorf("构建恢复规格失败:%v", specErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(selectedPaths) > 0 {
|
||||||
|
spec.SelectedPaths = selectedPaths
|
||||||
|
logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths))
|
||||||
|
}
|
||||||
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
|
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
|
||||||
if runnerErr != nil {
|
if runnerErr != nil {
|
||||||
errMessage = runnerErr.Error()
|
errMessage = runnerErr.Error()
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { getBackupRecordContents } from '../../services/backup-records'
|
import { getBackupRecordContents } from '../../services/backup-records'
|
||||||
import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records'
|
import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records'
|
||||||
@@ -9,15 +9,18 @@ interface BackupRecordContentsModalProps {
|
|||||||
visible: boolean
|
visible: boolean
|
||||||
recordId?: number
|
recordId?: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
// onRestoreSelected 提供时启用按需恢复:勾选条目后回调选中的归档路径。
|
||||||
|
onRestoreSelected?: (paths: string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
|
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
|
||||||
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
|
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
|
||||||
export function BackupRecordContentsModal({ visible, recordId, onClose }: BackupRecordContentsModalProps) {
|
export function BackupRecordContentsModal({ visible, recordId, onClose, onRestoreSelected }: BackupRecordContentsModalProps) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [contents, setContents] = useState<BackupRecordContents | null>(null)
|
const [contents, setContents] = useState<BackupRecordContents | null>(null)
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !recordId) {
|
if (!visible || !recordId) {
|
||||||
@@ -28,6 +31,7 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup
|
|||||||
setError('')
|
setError('')
|
||||||
setKeyword('')
|
setKeyword('')
|
||||||
setContents(null)
|
setContents(null)
|
||||||
|
setSelectedKeys([])
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getBackupRecordContents(recordId)
|
const data = await getBackupRecordContents(recordId)
|
||||||
@@ -71,11 +75,23 @@ export function BackupRecordContentsModal({ visible, recordId, onClose }: Backup
|
|||||||
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
|
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
|
||||||
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
|
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
|
||||||
</Typography.Text>
|
</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
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="path"
|
rowKey="path"
|
||||||
data={filtered}
|
data={filtered}
|
||||||
|
rowSelection={
|
||||||
|
onRestoreSelected
|
||||||
|
? { type: 'checkbox', selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys as string[]) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
pagination={{ pageSize: 50, sizeCanChange: false }}
|
pagination={{ pageSize: 50, sizeCanChange: false }}
|
||||||
scroll={{ y: 420 }}
|
scroll={{ y: 420 }}
|
||||||
columns={[
|
columns={[
|
||||||
|
|||||||
@@ -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() {
|
async function handleDelete() {
|
||||||
if (!recordId) {
|
if (!recordId) {
|
||||||
return
|
return
|
||||||
@@ -327,7 +347,12 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
|||||||
}}
|
}}
|
||||||
onConfirm={() => void handleConfirmRestore()}
|
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>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ export async function getRestoreRecord(id: number) {
|
|||||||
return unwrapApiEnvelope(response.data)
|
return unwrapApiEnvelope(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。
|
// startRestoreFromBackup 通过源备份记录启动恢复。selectedPaths 非空时为按需(选择性)恢复。
|
||||||
export async function startRestoreFromBackup(backupRecordId: number) {
|
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[]) {
|
||||||
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`)
|
const body = selectedPaths && selectedPaths.length > 0 ? { selectedPaths } : undefined
|
||||||
|
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
|
||||||
return unwrapApiEnvelope(response.data)
|
return unwrapApiEnvelope(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user