diff --git a/server/internal/model/backup_record.go b/server/internal/model/backup_record.go index 5cab79a..4d4c7a8 100644 --- a/server/internal/model/backup_record.go +++ b/server/internal/model/backup_record.go @@ -17,6 +17,7 @@ type BackupRecord struct { Status string `gorm:"size:20;index;not null" json:"status"` FileName string `gorm:"column:file_name;size:255" json:"fileName"` FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"` + Checksum string `gorm:"column:checksum;size:64" json:"checksum"` StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"` StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"` DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"` diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go index 6076e22..1cf575a 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -2,8 +2,11 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "hash" "io" "os" "path/filepath" @@ -253,10 +256,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba errMessage := "" var fileName string var fileSize int64 + var checksum string var storagePath string var uploadResults []StorageUploadResultItem completeRecord := func() { - if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, storagePath); finalizeErr != nil { + if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath); finalizeErr != nil { logger.Errorf("写回备份记录失败:%v", finalizeErr) } // 写入多目标上传结果 @@ -335,6 +339,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba // 并行上传到所有目标 uploadResults = make([]StorageUploadResultItem, len(targetIDs)) + var checksumOnce sync.Once var wg sync.WaitGroup for i, tid := range targetIDs { wg.Add(1) @@ -359,13 +364,25 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba } defer artifact.Close() logger.Infof("开始上传备份到存储目标:%s", targetName) - if uploadErr := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil { + // hashingReader: 上传过程中同步计算字节数 + SHA-256,单次读取零额外 I/O + hr := newHashingReader(artifact) + if uploadErr := provider.Upload(ctx, storagePath, hr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil { uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()} logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr) return } + // 完整性校验:对比实际传输字节数 + if hr.n != fileSize { + uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: fmt.Sprintf("完整性校验失败: 预期 %d bytes, 实际传输 %d bytes", fileSize, hr.n)} + logger.Errorf("存储目标 %s 完整性校验失败:预期 %d bytes, 实际传输 %d bytes", targetName, fileSize, hr.n) + _ = provider.Delete(ctx, storagePath) + return + } + // 取第一个成功目标的哈希写入 record(所有目标读同一文件,哈希一定相同) + targetChecksum := hr.Sum() + checksumOnce.Do(func() { checksum = targetChecksum }) uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "success", StoragePath: storagePath, FileSize: fileSize} - logger.Infof("存储目标 %s 上传成功", targetName) + logger.Infof("存储目标 %s 上传成功 (%d bytes, SHA-256=%s)", targetName, fileSize, targetChecksum) // 每个成功目标独立执行保留策略 if s.retention != nil { cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider) @@ -403,7 +420,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba } } -func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, storagePath string) error { +func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, checksum string, storagePath string) error { record, err := s.records.FindByID(ctx, recordID) if err != nil { return err @@ -415,6 +432,7 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model record.Status = status record.FileName = fileName record.FileSize = fileSize + record.Checksum = checksum record.StoragePath = storagePath record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds()) record.ErrorMessage = strings.TrimSpace(errorMessage) @@ -578,3 +596,28 @@ func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, st } return provider, target, nil } + +// hashingReader 在上传过程中同步计算字节数和 SHA-256,零额外 I/O +type hashingReader struct { + reader io.Reader + hash hash.Hash + n int64 +} + +func newHashingReader(reader io.Reader) *hashingReader { + h := sha256.New() + return &hashingReader{ + reader: io.TeeReader(reader, h), + hash: h, + } +} + +func (r *hashingReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + r.n += int64(n) + return n, err +} + +func (r *hashingReader) Sum() string { + return hex.EncodeToString(r.hash.Sum(nil)) +} diff --git a/server/internal/service/backup_execution_service_test.go b/server/internal/service/backup_execution_service_test.go index db89e15..057539f 100644 --- a/server/internal/service/backup_execution_service_test.go +++ b/server/internal/service/backup_execution_service_test.go @@ -55,7 +55,11 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewMySQLRunner(nil), backup.NewSQLiteRunner(), backup.NewPostgreSQLRunner(nil)) storageRegistry := storage.NewRegistry(localdisk.NewFactory()) retentionService := backupretention.NewService(records) - executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, "", 2) + tempDir := filepath.Join(baseDir, "tmp") + if err := os.MkdirAll(tempDir, 0o755); err != nil { + t.Fatalf("MkdirAll tempDir returned error: %v", err) + } + executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, tempDir, 2) recordService := NewBackupRecordService(records, executionService, logHub) return executionService, recordService, tasks, targets, records, sourceDir, storageDir } diff --git a/server/internal/service/backup_record_service.go b/server/internal/service/backup_record_service.go index 4a3e53c..d5ae215 100644 --- a/server/internal/service/backup_record_service.go +++ b/server/internal/service/backup_record_service.go @@ -30,6 +30,7 @@ type BackupRecordSummary struct { 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"` @@ -111,6 +112,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary { Status: item.Status, FileName: item.FileName, FileSize: item.FileSize, + Checksum: item.Checksum, StoragePath: item.StoragePath, DurationSeconds: item.DurationSeconds, ErrorMessage: item.ErrorMessage, diff --git a/web/src/pages/backup-records/BackupRecordsPage.tsx b/web/src/pages/backup-records/BackupRecordsPage.tsx index 6e69ddb..2543dcc 100644 --- a/web/src/pages/backup-records/BackupRecordsPage.tsx +++ b/web/src/pages/backup-records/BackupRecordsPage.tsx @@ -98,6 +98,11 @@ export function BackupRecordsPage() { {record.fileName || '-'} {formatBytes(record.fileSize)} + {record.checksum && ( + + SHA-256: {record.checksum.substring(0, 16)}... + + )} ), }, diff --git a/web/src/types/backup-records.ts b/web/src/types/backup-records.ts index ae7b8d0..2a59dc0 100644 --- a/web/src/types/backup-records.ts +++ b/web/src/types/backup-records.ts @@ -19,6 +19,7 @@ export interface BackupRecordSummary { status: BackupRecordStatus fileName: string fileSize: number + checksum: string storagePath: string durationSeconds: number errorMessage: string