Files
BackupX/server/internal/service/backup_record_service.go
Wu Qing 68bb964350 feat(backup): 新增备份内容浏览 (#90)
查看每次备份捕获的文件清单(路径/大小/目录),核对完整性、排查遗漏。清单取自全量备份记录,无需下载解压;差异记录回退基线清单。只读端点 + 前端可筛选弹窗。
2026-05-27 19:33:44 +08:00

228 lines
8.6 KiB
Go

package service
import (
"context"
"encoding/json"
"sort"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
type BackupRecordListInput struct {
TaskID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type BackupRecordSummary struct {
ID uint `json:"id"`
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
StorageTargetID uint `json:"storageTargetId"`
StorageTargetName string `json:"storageTargetName"`
Status string `json:"status"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
Checksum string `json:"checksum"`
StoragePath string `json:"storagePath"`
DurationSeconds int `json:"durationSeconds"`
ErrorMessage string `json:"errorMessage"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Locked bool `json:"locked"`
BackupKind string `json:"backupKind"`
}
type BackupRecordDetail struct {
BackupRecordSummary
LogContent string `json:"logContent"`
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
}
type BackupRecordService struct {
records repository.BackupRecordRepository
execution *BackupExecutionService
logHub *backup.LogHub
}
func NewBackupRecordService(records repository.BackupRecordRepository, execution *BackupExecutionService, logHub *backup.LogHub) *BackupRecordService {
return &BackupRecordService{records: records, execution: execution, logHub: logHub}
}
func (s *BackupRecordService) List(ctx context.Context, input BackupRecordListInput) ([]BackupRecordSummary, error) {
items, err := s.records.List(ctx, repository.BackupRecordListOptions{TaskID: input.TaskID, Status: strings.TrimSpace(input.Status), DateFrom: input.DateFrom, DateTo: input.DateTo, Limit: input.Limit, Offset: input.Offset})
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_LIST_FAILED", "无法获取备份记录列表", err)
}
result := make([]BackupRecordSummary, 0, len(items))
for _, item := range items {
result = append(result, toBackupRecordSummary(&item))
}
return result, nil
}
func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDetail, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", err)
}
return toBackupRecordDetail(item, s.logHub), nil
}
// BackupContentEntry 描述备份内单个条目(文件或目录),用于内容浏览。
type BackupContentEntry struct {
Path string `json:"path"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
}
// BackupRecordContents 是一次备份的内容清单视图。
type BackupRecordContents struct {
RecordID uint `json:"recordId"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
BasedOnFull uint `json:"basedOnFull,omitempty"` // 差异记录时,清单取自该基线全量
Entries []BackupContentEntry `json:"entries"`
}
const backupContentsMaxEntries = 10000
// ListContents 返回某备份记录的文件清单(仅文件类型的新全量备份会记录清单)。
// 差异记录回退到其基线全量的清单,近似展示恢复后的目录结构。无清单时返回明确错误。
func (s *BackupRecordService) ListContents(ctx context.Context, id uint) (*BackupRecordContents, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
}
manifestJSON := item.Manifest
basedOnFull := uint(0)
if strings.TrimSpace(manifestJSON) == "" && item.BaseRecordID != 0 {
if base, baseErr := s.records.FindByID(ctx, item.BaseRecordID); baseErr == nil && base != nil {
manifestJSON = base.Manifest
basedOnFull = base.ID
}
}
if strings.TrimSpace(manifestJSON) == "" {
return nil, apperror.New(422, "BACKUP_CONTENTS_UNAVAILABLE", "该备份未记录文件清单(仅文件类型的新全量备份支持内容浏览),请重新执行一次全量备份后再试。", nil)
}
manifest, decErr := backup.DecodeManifest([]byte(manifestJSON))
if decErr != nil {
return nil, apperror.Internal("BACKUP_CONTENTS_DECODE_FAILED", "解析备份清单失败", decErr)
}
entries := manifest.Entries
sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
total := len(entries)
truncated := false
if total > backupContentsMaxEntries {
entries = entries[:backupContentsMaxEntries]
truncated = true
}
result := &BackupRecordContents{RecordID: item.ID, Total: total, Truncated: truncated, BasedOnFull: basedOnFull, Entries: make([]BackupContentEntry, 0, len(entries))}
for _, e := range entries {
result.Entries = append(result.Entries, BackupContentEntry{Path: e.Path, Size: e.Size, IsDir: e.IsDir})
}
return result, nil
}
func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", err)
}
channel, cancel := s.logHub.Subscribe(id, buffer)
return channel, cancel, nil
}
func (s *BackupRecordService) Download(ctx context.Context, id uint) (*DownloadedArtifact, error) {
return s.execution.DownloadRecord(ctx, id)
}
func (s *BackupRecordService) Restore(ctx context.Context, id uint) error {
return s.execution.RestoreRecord(ctx, id)
}
func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
return s.execution.DeleteRecord(ctx, id)
}
// SetLock 设置或解除备份记录的保留锁定(法律保留)。
// 锁定后该记录免于保留期/数量自动清理,且禁止手动删除,直至显式解锁。
func (s *BackupRecordService) SetLock(ctx context.Context, id uint, locked bool) (*BackupRecordDetail, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
}
if item.Locked != locked {
item.Locked = locked
if err := s.records.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_RECORD_LOCK_FAILED", "无法更新备份锁定状态", err)
}
}
return toBackupRecordDetail(item, s.logHub), nil
}
func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
return BackupRecordSummary{
ID: item.ID,
TaskID: item.TaskID,
TaskName: item.Task.Name,
StorageTargetID: item.StorageTargetID,
StorageTargetName: item.StorageTarget.Name,
Status: item.Status,
FileName: item.FileName,
FileSize: item.FileSize,
Checksum: item.Checksum,
StoragePath: item.StoragePath,
DurationSeconds: item.DurationSeconds,
ErrorMessage: item.ErrorMessage,
StartedAt: item.StartedAt,
CompletedAt: item.CompletedAt,
Locked: item.Locked,
BackupKind: item.BackupKind,
}
}
func toBackupRecordDetail(item *model.BackupRecord, logHub *backup.LogHub) *BackupRecordDetail {
detail := &BackupRecordDetail{BackupRecordSummary: toBackupRecordSummary(item), LogContent: item.LogContent}
if item.Status == "running" && logHub != nil {
events := logHub.Snapshot(item.ID)
detail.LogEvents = events
if len(events) > 0 {
lines := make([]string, 0, len(events))
for _, event := range events {
lines = append(lines, event.Message)
}
detail.LogContent = strings.Join(lines, "\n")
}
}
// 解析多目标上传结果
if strings.TrimSpace(item.StorageUploadResults) != "" {
var uploadResults []StorageUploadResultItem
if err := json.Unmarshal([]byte(item.StorageUploadResults), &uploadResults); err == nil {
detail.StorageUploadResults = uploadResults
}
}
return detail
}