Files
BackupX/server/internal/http/backup_record_handler.go
Wu Qing 493e1faff5 feat(backup): 新增按需(选择性)文件恢复 (#91)
在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。
2026-05-27 19:50:50 +08:00

269 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupRecordHandler struct {
service *service.BackupRecordService
restoreService *service.RestoreService
auditService *service.AuditService
}
func NewBackupRecordHandler(recordService *service.BackupRecordService, restoreService *service.RestoreService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, restoreService: restoreService, auditService: auditService}
}
func (h *BackupRecordHandler) List(c *gin.Context) {
filter, err := buildRecordFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *BackupRecordHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
// Contents 返回备份记录的文件清单(内容浏览,只读)。
func (h *BackupRecordHandler) Contents(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
contents, err := h.service.ListContents(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, contents)
}
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("BACKUP_RECORD_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func (h *BackupRecordHandler) Download(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
result, err := h.service.Download(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
defer result.Reader.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", result.FileName))
c.Header("Content-Type", "application/octet-stream")
_, _ = io.Copy(c.Writer, result.Reader)
}
// Restore 启动一次异步恢复并返回 restoreRecordId实际执行路由由 RestoreService
// 根据 task.NodeID 决定(本地 Master or 远程 Agent
func (h *BackupRecordHandler) Restore(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if h.restoreService == nil {
response.Error(c, apperror.Internal("RESTORE_SERVICE_DISABLED", "恢复服务未启用", nil))
return
}
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
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
}
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
fmt.Sprintf("启动恢复 (备份记录 ID: %d, 恢复记录 ID: %d)", id, detail.ID))
response.Success(c, detail)
}
func (h *BackupRecordHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除备份记录 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}
// SetLock 设置/解除备份记录的保留锁定(法律保留)。
func (h *BackupRecordHandler) SetLock(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_RECORD_LOCK_INVALID", "锁定参数不合法", err))
return
}
detail, err := h.service.SetLock(c.Request.Context(), id, input.Locked)
if err != nil {
response.Error(c, err)
return
}
action, desc := "unlock", fmt.Sprintf("解除备份记录保留锁定 (ID: %d)", id)
if input.Locked {
action, desc = "lock", fmt.Sprintf("设置备份记录保留锁定 (ID: %d)", id)
}
recordAudit(c, h.auditService, "backup_record", action, "backup_record", fmt.Sprintf("%d", id), "", desc)
response.Success(c, detail)
}
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_RECORD_BATCH_INVALID", "批量删除参数不合法", err))
return
}
deleted := 0
for _, id := range input.IDs {
if err := h.service.Delete(c.Request.Context(), id); err == nil {
deleted++
}
}
recordAudit(c, h.auditService, "backup_record", "batch_delete", "backup_record", "", "", fmt.Sprintf("批量删除 %d 条备份记录", deleted))
response.Success(c, gin.H{"deleted": deleted})
}
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
var filter service.BackupRecordListInput
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
parsed, ok := parseUintString(taskIDValue)
if !ok {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "taskId 不合法", nil)
}
filter.TaskID = &parsed
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}
func parseUintString(value string) (uint, bool) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 0)
if err != nil {
return 0, false
}
return uint(parsed), true
}