Files
BackupX/server/internal/service/backup_task_service.go
2026-03-17 13:29:09 +08:00

418 lines
15 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage/codec"
)
const backupTaskMaskedValue = "********"
type BackupTaskUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr" binding:"max=64"`
SourcePath string `json:"sourcePath" binding:"max=500"`
ExcludePatterns []string `json:"excludePatterns"`
DBHost string `json:"dbHost" binding:"max=255"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser" binding:"max=100"`
DBPassword string `json:"dbPassword" binding:"max=255"`
DBName string `json:"dbName" binding:"max=255"`
DBPath string `json:"dbPath" binding:"max=500"`
StorageTargetID uint `json:"storageTargetId" binding:"required"`
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
}
type BackupTaskToggleInput struct {
Enabled *bool `json:"enabled"`
}
type BackupTaskSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr"`
StorageTargetID uint `json:"storageTargetId"`
StorageTargetName string `json:"storageTargetName"`
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
LastStatus string `json:"lastStatus"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BackupTaskDetail struct {
BackupTaskSummary
SourcePath string `json:"sourcePath"`
ExcludePatterns []string `json:"excludePatterns"`
DBHost string `json:"dbHost"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser"`
DBName string `json:"dbName"`
DBPath string `json:"dbPath"`
MaskedFields []string `json:"maskedFields,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type BackupTaskScheduler interface {
SyncTask(ctx context.Context, task *model.BackupTask) error
RemoveTask(ctx context.Context, taskID uint) error
}
type BackupTaskService struct {
tasks repository.BackupTaskRepository
targets repository.StorageTargetRepository
cipher *codec.ConfigCipher
scheduler BackupTaskScheduler
}
func NewBackupTaskService(
tasks repository.BackupTaskRepository,
targets repository.StorageTargetRepository,
cipher *codec.ConfigCipher,
) *BackupTaskService {
return &BackupTaskService{tasks: tasks, targets: targets, cipher: cipher}
}
func (s *BackupTaskService) SetScheduler(scheduler BackupTaskScheduler) {
s.scheduler = scheduler
}
func (s *BackupTaskService) List(ctx context.Context) ([]BackupTaskSummary, error) {
items, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_LIST_FAILED", "无法获取备份任务列表", err)
}
result := make([]BackupTaskSummary, 0, len(items))
for _, item := range items {
result = append(result, toBackupTaskSummary(&item))
}
return result, nil
}
func (s *BackupTaskService) Get(ctx context.Context, id uint) (*BackupTaskDetail, error) {
item, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if item == nil {
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
return s.toDetail(item)
}
func (s *BackupTaskService) Create(ctx context.Context, input BackupTaskUpsertInput) (*BackupTaskDetail, error) {
input.Type = normalizeBackupTaskType(input.Type)
if err := s.validateInput(ctx, nil, input); err != nil {
return nil, err
}
existing, err := s.tasks.FindByName(ctx, strings.TrimSpace(input.Name))
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_LOOKUP_FAILED", "无法检查备份任务名称", err)
}
if existing != nil {
return nil, apperror.Conflict("BACKUP_TASK_NAME_EXISTS", "备份任务名称已存在", nil)
}
item, err := s.buildTask(nil, input)
if err != nil {
return nil, err
}
if err := s.tasks.Create(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_CREATE_FAILED", "无法创建备份任务", err)
}
if s.scheduler != nil {
if err := s.scheduler.SyncTask(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法同步备份任务调度", err)
}
}
return s.Get(ctx, item.ID)
}
func (s *BackupTaskService) Update(ctx context.Context, id uint, input BackupTaskUpsertInput) (*BackupTaskDetail, error) {
input.Type = normalizeBackupTaskType(input.Type)
existing, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if existing == nil {
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
if err := s.validateInput(ctx, existing, input); err != nil {
return nil, err
}
sameName, err := s.tasks.FindByName(ctx, strings.TrimSpace(input.Name))
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_LOOKUP_FAILED", "无法检查备份任务名称", err)
}
if sameName != nil && sameName.ID != existing.ID {
return nil, apperror.Conflict("BACKUP_TASK_NAME_EXISTS", "备份任务名称已存在", nil)
}
item, err := s.buildTask(existing, input)
if err != nil {
return nil, err
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
if err := s.tasks.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新备份任务", err)
}
if s.scheduler != nil {
if err := s.scheduler.SyncTask(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法同步备份任务调度", err)
}
}
return s.Get(ctx, item.ID)
}
func (s *BackupTaskService) Delete(ctx context.Context, id uint) error {
existing, err := s.tasks.FindByID(ctx, id)
if err != nil {
return apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if existing == nil {
return apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
if s.scheduler != nil {
if err := s.scheduler.RemoveTask(ctx, id); err != nil {
return apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法移除备份任务调度", err)
}
}
if err := s.tasks.Delete(ctx, id); err != nil {
return apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err)
}
if s.scheduler != nil {
_ = s.scheduler.RemoveTask(ctx, id)
}
return nil
}
func (s *BackupTaskService) Toggle(ctx context.Context, id uint, enabled bool) (*BackupTaskSummary, error) {
item, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
if item == nil {
return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
item.Enabled = enabled
if err := s.tasks.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新备份任务状态", err)
}
if s.scheduler != nil {
if err := s.scheduler.SyncTask(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_TASK_SCHEDULE_FAILED", "无法同步备份任务调度", err)
}
}
returnPtr, err := s.tasks.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取备份任务详情", err)
}
returnValue := toBackupTaskSummary(returnPtr)
return &returnValue, nil
}
func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.BackupTask, input BackupTaskUpsertInput) error {
if strings.TrimSpace(input.Name) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "任务名称不能为空", nil)
}
if input.StorageTargetID == 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择存储目标", nil)
}
target, err := s.targets.FindByID(ctx, input.StorageTargetID)
if err != nil {
return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err)
}
if target == nil {
return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
}
if input.RetentionDays < 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
}
if input.MaxBackups < 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "最大保留份数不能小于 0", nil)
}
if input.Compression == "" {
input.Compression = "gzip"
}
if strings.TrimSpace(input.CronExpr) != "" && len(strings.Fields(strings.TrimSpace(input.CronExpr))) < 5 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "Cron 表达式格式不正确", nil)
}
passwordRequired := existing == nil || existing.DBPasswordCiphertext == ""
return validateTaskTypeSpecificFields(input, passwordRequired)
}
func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequired bool) error {
switch normalizeBackupTaskType(input.Type) {
case "file":
if strings.TrimSpace(input.SourcePath) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil)
}
case "mysql", "postgresql":
if strings.TrimSpace(input.DBHost) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库主机不能为空", nil)
}
if input.DBPort <= 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库端口必须大于 0", nil)
}
if strings.TrimSpace(input.DBUser) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库用户名不能为空", nil)
}
if passwordRequired && strings.TrimSpace(input.DBPassword) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库密码不能为空", nil)
}
if strings.TrimSpace(input.DBName) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库名称不能为空", nil)
}
case "sqlite":
if strings.TrimSpace(input.DBPath) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "SQLite 备份必须填写数据库文件路径", nil)
}
default:
return apperror.BadRequest("BACKUP_TASK_INVALID", "不支持的备份任务类型", nil)
}
return nil
}
func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTaskUpsertInput) (*model.BackupTask, error) {
excludePatterns, err := encodeExcludePatterns(input.ExcludePatterns)
if err != nil {
return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "排除规则格式不合法", err)
}
passwordCiphertext := ""
if existing != nil {
passwordCiphertext = existing.DBPasswordCiphertext
}
if text := strings.TrimSpace(input.DBPassword); text != "" && text != backupTaskMaskedValue {
ciphertext, encryptErr := s.cipher.Encrypt([]byte(text))
if encryptErr != nil {
return nil, apperror.Internal("BACKUP_TASK_ENCRYPT_FAILED", "无法保存数据库密码", encryptErr)
}
passwordCiphertext = ciphertext
}
compression := strings.TrimSpace(input.Compression)
if compression == "" {
compression = "gzip"
}
maxBackups := input.MaxBackups
if maxBackups == 0 {
maxBackups = 10
}
item := &model.BackupTask{
Name: strings.TrimSpace(input.Name),
Type: normalizeBackupTaskType(input.Type),
Enabled: input.Enabled,
CronExpr: strings.TrimSpace(input.CronExpr),
SourcePath: strings.TrimSpace(input.SourcePath),
ExcludePatterns: excludePatterns,
DBHost: strings.TrimSpace(input.DBHost),
DBPort: input.DBPort,
DBUser: strings.TrimSpace(input.DBUser),
DBPasswordCiphertext: passwordCiphertext,
DBName: strings.TrimSpace(input.DBName),
DBPath: strings.TrimSpace(input.DBPath),
StorageTargetID: input.StorageTargetID,
RetentionDays: input.RetentionDays,
Compression: compression,
Encrypt: input.Encrypt,
MaxBackups: maxBackups,
LastStatus: "idle",
}
if existing != nil {
item.LastRunAt = existing.LastRunAt
item.LastStatus = existing.LastStatus
item.CreatedAt = existing.CreatedAt
}
return item, nil
}
func (s *BackupTaskService) toDetail(item *model.BackupTask) (*BackupTaskDetail, error) {
excludePatterns, err := decodeExcludePatterns(item.ExcludePatterns)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析备份任务配置", err)
}
detail := &BackupTaskDetail{
BackupTaskSummary: toBackupTaskSummary(item),
SourcePath: item.SourcePath,
ExcludePatterns: excludePatterns,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
CreatedAt: item.CreatedAt,
}
if item.DBPasswordCiphertext != "" {
detail.MaskedFields = []string{"dbPassword"}
}
return detail, nil
}
func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
storageTargetName := ""
if item != nil {
storageTargetName = item.StorageTarget.Name
}
return BackupTaskSummary{
ID: item.ID,
Name: item.Name,
Type: normalizeBackupTaskType(item.Type),
Enabled: item.Enabled,
CronExpr: item.CronExpr,
StorageTargetID: item.StorageTargetID,
StorageTargetName: storageTargetName,
RetentionDays: item.RetentionDays,
Compression: item.Compression,
Encrypt: item.Encrypt,
MaxBackups: item.MaxBackups,
LastRunAt: item.LastRunAt,
LastStatus: item.LastStatus,
UpdatedAt: item.UpdatedAt,
}
}
func encodeExcludePatterns(value []string) (string, error) {
if len(value) == 0 {
return "[]", nil
}
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
func decodeExcludePatterns(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return []string{}, nil
}
var items []string
if err := json.Unmarshal([]byte(value), &items); err != nil {
return nil, err
}
return items, nil
}
func normalizeBackupTaskType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "pgsql" {
return "postgresql"
}
return normalized
}