Files
BackupX/server/internal/service/backup_task_service.go
Wu Qing eff48342c8 功能: v2.2 节点池调度 + Grafana Dashboard + 版本漂移 UI (#49)
节点池动态调度(企业集群核心需求):
- model.Node 新增 Labels CSV;Node.HasLabel / LabelSet 辅助方法
- model.BackupTask 新增 NodePoolTag;与 NodeID 互斥(校验层拒绝同时设置)
- BackupExecutionService.selectPoolNode:匹配标签的在线节点中选"运行中任务最少"
  并列按 ID 升序稳定;空池返回 NODE_POOL_EMPTY 让用户立即感知
- 选中节点仅写 BackupRecord,不回写 task.NodeID —— 每次执行重选实现真轮转均衡

Grafana Dashboard(v2.1 指标的可视化闭环):
- deploy/grafana/backupx-dashboard.json:11 个面板覆盖概览/时序/容量/集群
- deploy/grafana/README.md:Prometheus 抓取配置 + 告警建议
- release workflow 打包 grafana/ + nginx.conf 到 tar.gz

前端:
- 节点列表:Agent 版本 vs Master 不一致时橙红 Tag + Tooltip 提示升级
- 节点列表新增"标签/节点池"列,支持 CSV 编辑 + 并发/带宽一起改
- 任务表单新增 NodePoolTag 输入框,与节点选择器互斥禁用

测试:
- model/node_label_test.go:HasLabel / LabelSet / nil 安全
- service/node_pool_scheduler_test.go:负载最低优先 / 空池错误 / nil repo 降级
- go test ./... + npm run build 全绿
2026-04-21 14:05:48 +08:00

905 lines
31 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 service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"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 saphana"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr" binding:"max=64"`
SourcePath string `json:"sourcePath" binding:"max=500"`
SourcePaths []string `json:"sourcePaths"`
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"` // deprecated: 向后兼容
StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标
NodeID uint `json:"nodeId"` // 执行节点0 = 本机 Master 或节点池)
// NodePoolTag 节点池标签。NodeID=0 且本字段非空时,调度器动态从 Labels 命中的在线节点中选负载最低者。
NodePoolTag string `json:"nodePoolTag" binding:"max=64"`
Tags string `json:"tags" binding:"max=500"` // 逗号分隔标签
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
// ExtraConfig 类型特有扩展配置(如 SAP HANA 的 backupLevel/backupChannels
ExtraConfig map[string]any `json:"extraConfig"`
// 验证(恢复演练)配置
VerifyEnabled bool `json:"verifyEnabled"`
VerifyCronExpr string `json:"verifyCronExpr" binding:"max=64"`
VerifyMode string `json:"verifyMode" binding:"omitempty,oneof=quick deep"`
// SLA 配置
SLAHoursRPO int `json:"slaHoursRpo"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails"`
// 备份复制目标存储 ID 列表3-2-1 规则)
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
// 维护窗口CSV详见 backup/window.go
MaintenanceWindows string `json:"maintenanceWindows" binding:"max=500"`
// 依赖的上游任务 ID上游成功后自动触发本任务
DependsOnTaskIDs []uint `json:"dependsOnTaskIds"`
}
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"` // deprecated: 取第一个
StorageTargetName string `json:"storageTargetName"` // deprecated: 取第一个
StorageTargetIDs []uint `json:"storageTargetIds"`
StorageTargetNames []string `json:"storageTargetNames"`
NodeID uint `json:"nodeId"`
NodeName string `json:"nodeName,omitempty"`
NodePoolTag string `json:"nodePoolTag,omitempty"`
Tags string `json:"tags"`
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"`
// 验证与 SLA 元信息
VerifyEnabled bool `json:"verifyEnabled"`
VerifyCronExpr string `json:"verifyCronExpr"`
VerifyMode string `json:"verifyMode"`
SLAHoursRPO int `json:"slaHoursRpo"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails"`
// 备份复制目标3-2-1
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
MaintenanceWindows string `json:"maintenanceWindows"`
DependsOnTaskIDs []uint `json:"dependsOnTaskIds"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BackupTaskDetail struct {
BackupTaskSummary
SourcePath string `json:"sourcePath"`
SourcePaths []string `json:"sourcePaths"`
ExcludePatterns []string `json:"excludePatterns"`
DBHost string `json:"dbHost"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser"`
DBName string `json:"dbName"`
DBPath string `json:"dbPath"`
ExtraConfig map[string]any `json:"extraConfig,omitempty"`
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
records repository.BackupRecordRepository
nodes repository.NodeRepository
storageRegistry *storage.Registry
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}
}
// SetRecordsAndStorage 注入备份记录仓库和存储注册表,用于任务删除时清理远端文件。
func (s *BackupTaskService) SetRecordsAndStorage(records repository.BackupRecordRepository, registry *storage.Registry) {
s.records = records
s.storageRegistry = registry
}
// SetNodeRepository 注入节点仓库用于校验任务绑定的 NodeID 合法。
func (s *BackupTaskService) SetNodeRepository(nodes repository.NodeRepository) {
s.nodes = nodes
}
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
}
// ListTags 返回全系统所有任务使用过的唯一标签。
func (s *BackupTaskService) ListTags(ctx context.Context) ([]string, error) {
tags, err := s.tasks.DistinctTags(ctx)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_TAG_LIST_FAILED", "无法获取任务标签", err)
}
return tags, nil
}
// BatchResult 单条批量操作结果。best-effort失败不中断其他。
type BatchResult struct {
ID uint `json:"id"`
Name string `json:"name,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// BatchToggle 批量启停任务。
func (s *BackupTaskService) BatchToggle(ctx context.Context, ids []uint, enabled bool) []BatchResult {
results := make([]BatchResult, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
summary, err := s.Toggle(ctx, id, enabled)
item := BatchResult{ID: id, Success: err == nil}
if err != nil {
item.Error = appErrorMessage(err)
} else if summary != nil {
item.Name = summary.Name
}
results = append(results, item)
}
return results
}
// BatchDeleteTasks 批量删除任务。
func (s *BackupTaskService) BatchDeleteTasks(ctx context.Context, ids []uint) []BatchResult {
results := make([]BatchResult, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
result, err := s.Delete(ctx, id)
item := BatchResult{ID: id, Success: err == nil}
if err != nil {
item.Error = appErrorMessage(err)
} else if result != nil {
item.Name = result.TaskName
}
results = append(results, item)
}
return results
}
// hasCyclicDependency DFS 查找是否存在从 candidate 上游链回到 taskID 的路径。
// 保守实现:遍历 depth 超过 32 视为潜在循环并返回 true。
func (s *BackupTaskService) hasCyclicDependency(ctx context.Context, taskID uint, candidates []uint) bool {
visited := map[uint]bool{}
var dfs func(id uint, depth int) bool
dfs = func(id uint, depth int) bool {
if depth > 32 {
return true
}
if id == taskID {
return true
}
if visited[id] {
return false
}
visited[id] = true
upstream, err := s.tasks.FindByID(ctx, id)
if err != nil || upstream == nil {
return false
}
for _, up := range parseUintCSV(upstream.DependsOnTaskIDs) {
if dfs(up, depth+1) {
return true
}
}
return false
}
for _, c := range candidates {
if dfs(c, 0) {
return true
}
}
return false
}
// TriggerDependents 上游任务成功后找出所有 depends_on 中含有 upstreamID 的下游任务。
// 供 BackupExecutionService 调用,避免后者直接触达 backup_task_repository。
func (s *BackupTaskService) TriggerDependents(ctx context.Context, upstreamID uint) ([]uint, error) {
items, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return nil, err
}
var triggers []uint
for _, item := range items {
if !item.Enabled {
continue
}
for _, dep := range parseUintCSV(item.DependsOnTaskIDs) {
if dep == upstreamID {
triggers = append(triggers, item.ID)
break
}
}
}
return triggers, nil
}
// appErrorMessage 提取 apperror 的可读消息,回退到 error.Error()。
func appErrorMessage(err error) string {
if err == nil {
return ""
}
if appErr, ok := err.(*apperror.AppError); ok {
return appErr.Message
}
return err.Error()
}
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)
}
// DeleteResult 描述任务删除的结果信息,用于审计日志。
type DeleteResult struct {
TaskName string
RecordCount int
CleanedFiles int
}
func (s *BackupTaskService) Delete(ctx context.Context, id uint) (*DeleteResult, error) {
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 s.scheduler != nil {
_ = s.scheduler.RemoveTask(ctx, id)
}
// 清理远端存储文件(尽力而为,不阻止删除)
result := &DeleteResult{TaskName: existing.Name}
result.RecordCount, result.CleanedFiles = s.cleanupRemoteFiles(ctx, id)
if err := s.tasks.Delete(ctx, id); err != nil {
return nil, apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err)
}
return result, nil
}
// cleanupRemoteFiles 尽力删除任务相关的远端备份文件,返回记录数和成功删除的文件数。
func (s *BackupTaskService) cleanupRemoteFiles(ctx context.Context, taskID uint) (recordCount int, cleanedFiles int) {
if s.records == nil || s.storageRegistry == nil {
return 0, 0
}
records, err := s.records.ListByTask(ctx, taskID)
if err != nil {
return 0, 0
}
recordCount = len(records)
// 缓存 provider 避免同一存储目标重复创建连接
providerCache := make(map[uint]storage.StorageProvider)
for _, record := range records {
if strings.TrimSpace(record.StoragePath) == "" {
continue
}
provider, ok := providerCache[record.StorageTargetID]
if !ok {
provider, err = s.resolveStorageProvider(ctx, record.StorageTargetID)
if err != nil {
continue
}
providerCache[record.StorageTargetID] = provider
}
if err := provider.Delete(ctx, record.StoragePath); err == nil {
cleanedFiles++
}
}
return recordCount, cleanedFiles
}
func (s *BackupTaskService) resolveStorageProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
target, err := s.targets.FindByID(ctx, targetID)
if err != nil || target == nil {
return nil, fmt.Errorf("target %d not found", targetID)
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, err
}
provider, err := s.storageRegistry.Create(ctx, target.Type, configMap)
if err != nil {
return nil, err
}
return provider, 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
}
// resolveStorageTargetIDs 统一处理新旧字段,返回有效的存储目标 ID 列表
func resolveStorageTargetIDs(input BackupTaskUpsertInput) []uint {
if len(input.StorageTargetIDs) > 0 {
return input.StorageTargetIDs
}
if input.StorageTargetID > 0 {
return []uint{input.StorageTargetID}
}
return 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)
}
targetIDs := resolveStorageTargetIDs(input)
if len(targetIDs) == 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择至少一个存储目标", nil)
}
for _, tid := range targetIDs {
target, err := s.targets.FindByID(ctx, tid)
if err != nil {
return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err)
}
if target == nil {
return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", fmt.Sprintf("关联的存储目标 %d 不存在", tid), nil)
}
}
if input.NodeID > 0 && s.nodes != nil {
node, err := s.nodes.FindByID(ctx, input.NodeID)
if err != nil {
return apperror.Internal("BACKUP_TASK_NODE_LOOKUP_FAILED", "无法校验执行节点", err)
}
if node == nil {
return apperror.BadRequest("BACKUP_TASK_INVALID", "所选执行节点不存在", nil)
}
}
// 节点池与固定节点互斥:固定节点已确定执行位置,不再动态调度
if input.NodeID > 0 && strings.TrimSpace(input.NodePoolTag) != "" {
return apperror.BadRequest("BACKUP_TASK_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)
}
if input.VerifyEnabled {
if strings.TrimSpace(input.VerifyCronExpr) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "启用验证演练时必须填写验证 Cron 表达式", nil)
}
if len(strings.Fields(strings.TrimSpace(input.VerifyCronExpr))) < 5 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "验证 Cron 表达式格式不正确", nil)
}
}
if strings.TrimSpace(input.MaintenanceWindows) != "" {
if err := backup.ValidateMaintenanceWindows(input.MaintenanceWindows); err != nil {
return apperror.BadRequest("BACKUP_TASK_INVALID", err.Error(), err)
}
}
// 依赖检查:每个上游任务必须存在 + 不能依赖自己 + 无循环
if len(input.DependsOnTaskIDs) > 0 {
currentID := uint(0)
if existing != nil {
currentID = existing.ID
}
for _, dep := range input.DependsOnTaskIDs {
if dep == 0 {
continue
}
if dep == currentID {
return apperror.BadRequest("BACKUP_TASK_INVALID", "不能把任务自己设为上游依赖", nil)
}
upstream, err := s.tasks.FindByID(ctx, dep)
if err != nil {
return apperror.Internal("BACKUP_TASK_DEP_LOOKUP_FAILED", "无法校验上游任务", err)
}
if upstream == nil {
return apperror.BadRequest("BACKUP_TASK_INVALID", fmt.Sprintf("上游任务 %d 不存在", dep), nil)
}
}
if currentID > 0 && s.hasCyclicDependency(ctx, currentID, input.DependsOnTaskIDs) {
return apperror.BadRequest("BACKUP_TASK_INVALID", "依赖关系会形成循环", nil)
}
}
passwordRequired := existing == nil || existing.DBPasswordCiphertext == ""
return validateTaskTypeSpecificFields(input, passwordRequired)
}
func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequired bool) error {
switch normalizeBackupTaskType(input.Type) {
case "file":
hasSourcePaths := len(resolveSourcePaths(input)) > 0
if !hasSourcePaths {
return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil)
}
case "mysql", "postgresql", "saphana":
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)
}
sourcePathsJSON, err := encodeSourcePaths(resolveSourcePaths(input))
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
}
targetIDs := resolveStorageTargetIDs(input)
// 保持旧字段兼容:取第一个
primaryTargetID := uint(0)
if len(targetIDs) > 0 {
primaryTargetID = targetIDs[0]
}
// 构建多对多关联
storageTargets := make([]model.StorageTarget, len(targetIDs))
for i, tid := range targetIDs {
storageTargets[i] = model.StorageTarget{ID: tid}
}
// 向后兼容SourcePath 取第一个
resolvedPaths := resolveSourcePaths(input)
primarySourcePath := strings.TrimSpace(input.SourcePath)
if len(resolvedPaths) > 0 {
primarySourcePath = resolvedPaths[0]
}
extraConfigJSON, err := encodeExtraConfig(input.ExtraConfig)
if err != nil {
return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "扩展配置格式不合法", err)
}
item := &model.BackupTask{
Name: strings.TrimSpace(input.Name),
Type: normalizeBackupTaskType(input.Type),
Enabled: input.Enabled,
CronExpr: strings.TrimSpace(input.CronExpr),
SourcePath: primarySourcePath,
SourcePaths: sourcePathsJSON,
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),
ExtraConfig: extraConfigJSON,
StorageTargetID: primaryTargetID,
StorageTargets: storageTargets,
NodeID: input.NodeID,
NodePoolTag: strings.TrimSpace(input.NodePoolTag),
Tags: strings.TrimSpace(input.Tags),
RetentionDays: input.RetentionDays,
Compression: compression,
Encrypt: input.Encrypt,
MaxBackups: maxBackups,
LastStatus: "idle",
VerifyEnabled: input.VerifyEnabled,
VerifyCronExpr: strings.TrimSpace(input.VerifyCronExpr),
VerifyMode: normalizeVerifyMode(input.VerifyMode),
SLAHoursRPO: maxInt(0, input.SLAHoursRPO),
AlertOnConsecutiveFails: alertThreshold(input.AlertOnConsecutiveFails),
ReplicationTargetIDs: encodeUintCSV(input.ReplicationTargetIDs),
MaintenanceWindows: strings.TrimSpace(input.MaintenanceWindows),
DependsOnTaskIDs: encodeUintCSV(input.DependsOnTaskIDs),
}
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)
}
sourcePaths, err := decodeSourcePaths(item.SourcePaths)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
}
extraConfig, err := decodeExtraConfig(item.ExtraConfig)
if err != nil {
return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
}
detail := &BackupTaskDetail{
BackupTaskSummary: toBackupTaskSummary(item),
SourcePath: item.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludePatterns,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
ExtraConfig: extraConfig,
CreatedAt: item.CreatedAt,
}
if item.DBPasswordCiphertext != "" {
detail.MaskedFields = []string{"dbPassword"}
}
return detail, nil
}
func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
// 从多对多关联提取 IDs 和 Names
var targetIDs []uint
var targetNames []string
if len(item.StorageTargets) > 0 {
for _, t := range item.StorageTargets {
targetIDs = append(targetIDs, t.ID)
targetNames = append(targetNames, t.Name)
}
} else if item.StorageTargetID > 0 {
// 回退到旧字段
targetIDs = []uint{item.StorageTargetID}
targetNames = []string{item.StorageTarget.Name}
}
// 向后兼容:取第一个
primaryID := uint(0)
primaryName := ""
if len(targetIDs) > 0 {
primaryID = targetIDs[0]
}
if len(targetNames) > 0 {
primaryName = targetNames[0]
}
return BackupTaskSummary{
ID: item.ID,
Name: item.Name,
Type: normalizeBackupTaskType(item.Type),
Enabled: item.Enabled,
CronExpr: item.CronExpr,
StorageTargetID: primaryID,
StorageTargetName: primaryName,
StorageTargetIDs: targetIDs,
StorageTargetNames: targetNames,
NodeID: item.NodeID,
NodeName: item.Node.Name,
NodePoolTag: item.NodePoolTag,
Tags: item.Tags,
RetentionDays: item.RetentionDays,
Compression: item.Compression,
Encrypt: item.Encrypt,
MaxBackups: item.MaxBackups,
LastRunAt: item.LastRunAt,
LastStatus: item.LastStatus,
VerifyEnabled: item.VerifyEnabled,
VerifyCronExpr: item.VerifyCronExpr,
VerifyMode: item.VerifyMode,
SLAHoursRPO: item.SLAHoursRPO,
AlertOnConsecutiveFails: item.AlertOnConsecutiveFails,
ReplicationTargetIDs: parseUintCSV(item.ReplicationTargetIDs),
MaintenanceWindows: item.MaintenanceWindows,
DependsOnTaskIDs: parseUintCSV(item.DependsOnTaskIDs),
UpdatedAt: item.UpdatedAt,
}
}
// encodeUintCSV 把 uint 切片编码为 CSV 字符串(去重保序)。
func encodeUintCSV(ids []uint) string {
if len(ids) == 0 {
return ""
}
seen := map[uint]bool{}
parts := make([]string, 0, len(ids))
for _, id := range ids {
if id == 0 || seen[id] {
continue
}
seen[id] = true
parts = append(parts, strconv.FormatUint(uint64(id), 10))
}
return strings.Join(parts, ",")
}
// normalizeVerifyMode 规范化验证模式,未知值默认 quick。
func normalizeVerifyMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "deep":
return model.VerificationModeDeep
default:
return model.VerificationModeQuick
}
}
// alertThreshold 连续失败告警阈值下限为 1。
func alertThreshold(value int) int {
if value <= 0 {
return 1
}
return value
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
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
}
// resolveSourcePaths 统一处理 sourcePaths / sourcePath返回有效路径列表
func resolveSourcePaths(input BackupTaskUpsertInput) []string {
if len(input.SourcePaths) > 0 {
var cleaned []string
for _, p := range input.SourcePaths {
if trimmed := strings.TrimSpace(p); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) > 0 {
return cleaned
}
}
if sp := strings.TrimSpace(input.SourcePath); sp != "" {
return []string{sp}
}
return nil
}
func encodeSourcePaths(paths []string) (string, error) {
if len(paths) == 0 {
return "[]", nil
}
encoded, err := json.Marshal(paths)
if err != nil {
return "", err
}
return string(encoded), nil
}
func decodeSourcePaths(value string) ([]string, error) {
if strings.TrimSpace(value) == "" || 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 encodeExtraConfig(value map[string]any) (string, error) {
if len(value) == 0 {
return "", nil
}
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
func decodeExtraConfig(value string) (map[string]any, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" || trimmed == "{}" {
return nil, nil
}
result := map[string]any{}
if err := json.Unmarshal([]byte(trimmed), &result); err != nil {
return nil, err
}
return result, nil
}
func normalizeBackupTaskType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "pgsql" {
return "postgresql"
}
return normalized
}