mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +08:00
节点池动态调度(企业集群核心需求): - 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 全绿
905 lines
31 KiB
Go
905 lines
31 KiB
Go
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
|
||
}
|