mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +08:00
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力
围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。
## 集群能力
- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)
## 企业功能
- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出
## 规模化运维
- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)
## 体验 & 可达性
- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)
## 合规 & 可部署
- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)
## 破坏性变更
- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
(原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)
* 修复: CodeQL 安全扫描告警
- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)
* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper
- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
241 lines
8.4 KiB
Go
241 lines
8.4 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"backupx/server/internal/apperror"
|
||
"backupx/server/internal/model"
|
||
"backupx/server/internal/repository"
|
||
)
|
||
|
||
// TaskTemplateService 管理任务模板 + 一键批量创建任务。
|
||
type TaskTemplateService struct {
|
||
templates repository.TaskTemplateRepository
|
||
tasks *BackupTaskService
|
||
}
|
||
|
||
func NewTaskTemplateService(templates repository.TaskTemplateRepository, tasks *BackupTaskService) *TaskTemplateService {
|
||
return &TaskTemplateService{templates: templates, tasks: tasks}
|
||
}
|
||
|
||
type TaskTemplateSummary struct {
|
||
ID uint `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
TaskType string `json:"taskType"`
|
||
CreatedBy string `json:"createdBy"`
|
||
CreatedAt string `json:"createdAt"`
|
||
UpdatedAt string `json:"updatedAt"`
|
||
}
|
||
|
||
type TaskTemplateDetail struct {
|
||
TaskTemplateSummary
|
||
Payload BackupTaskUpsertInput `json:"payload"`
|
||
}
|
||
|
||
// TaskTemplateUpsertInput 创建/更新模板时的输入。
|
||
// Payload 字段与 BackupTaskUpsertInput 复用同一结构。
|
||
type TaskTemplateUpsertInput struct {
|
||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||
Description string `json:"description" binding:"max=500"`
|
||
Payload BackupTaskUpsertInput `json:"payload" binding:"required"`
|
||
}
|
||
|
||
// TaskTemplateApplyInput 应用模板批量创建任务。
|
||
// 每个 Variables 条目会用 Variables 中的字段覆盖模板 Payload 生成一个新任务:
|
||
// - name 必填(覆盖模板 Name,任务命名)
|
||
// - sourcePath / sourcePaths / dbHost / dbName 若提供则覆盖
|
||
type TaskTemplateApplyInput struct {
|
||
Variables []TaskTemplateVariables `json:"variables" binding:"required,min=1,max=100"`
|
||
}
|
||
|
||
type TaskTemplateVariables struct {
|
||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||
SourcePath string `json:"sourcePath"`
|
||
SourcePaths []string `json:"sourcePaths"`
|
||
DBHost string `json:"dbHost"`
|
||
DBName string `json:"dbName"`
|
||
Tags string `json:"tags"`
|
||
NodeID *uint `json:"nodeId"`
|
||
}
|
||
|
||
// TaskTemplateApplyResult 单个任务的创建结果。
|
||
type TaskTemplateApplyResult struct {
|
||
Name string `json:"name"`
|
||
TaskID uint `json:"taskId,omitempty"`
|
||
Success bool `json:"success"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
func (s *TaskTemplateService) List(ctx context.Context) ([]TaskTemplateSummary, error) {
|
||
items, err := s.templates.List(ctx)
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_LIST_FAILED", "无法获取任务模板列表", err)
|
||
}
|
||
result := make([]TaskTemplateSummary, 0, len(items))
|
||
for i := range items {
|
||
result = append(result, toTemplateSummary(&items[i]))
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *TaskTemplateService) Get(ctx context.Context, id uint) (*TaskTemplateDetail, error) {
|
||
item, err := s.templates.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_GET_FAILED", "无法获取任务模板", err)
|
||
}
|
||
if item == nil {
|
||
return nil, apperror.New(404, "TASK_TEMPLATE_NOT_FOUND", "任务模板不存在", nil)
|
||
}
|
||
var payload BackupTaskUpsertInput
|
||
if err := json.Unmarshal([]byte(item.Payload), &payload); err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_DECODE_FAILED", "无法解析模板内容", err)
|
||
}
|
||
detail := &TaskTemplateDetail{TaskTemplateSummary: toTemplateSummary(item), Payload: payload}
|
||
return detail, nil
|
||
}
|
||
|
||
func (s *TaskTemplateService) Create(ctx context.Context, createdBy string, input TaskTemplateUpsertInput) (*TaskTemplateDetail, error) {
|
||
if strings.TrimSpace(input.Name) == "" {
|
||
return nil, apperror.BadRequest("TASK_TEMPLATE_INVALID", "名称不能为空", nil)
|
||
}
|
||
existing, err := s.templates.FindByName(ctx, strings.TrimSpace(input.Name))
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_LOOKUP_FAILED", "无法校验模板名", err)
|
||
}
|
||
if existing != nil {
|
||
return nil, apperror.Conflict("TASK_TEMPLATE_NAME_EXISTS", "模板名称已存在", nil)
|
||
}
|
||
payloadJSON, err := json.Marshal(input.Payload)
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_ENCODE_FAILED", "无法序列化模板参数", err)
|
||
}
|
||
item := &model.TaskTemplate{
|
||
Name: strings.TrimSpace(input.Name),
|
||
Description: strings.TrimSpace(input.Description),
|
||
TaskType: strings.TrimSpace(input.Payload.Type),
|
||
Payload: string(payloadJSON),
|
||
CreatedBy: strings.TrimSpace(createdBy),
|
||
}
|
||
if err := s.templates.Create(ctx, item); err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_CREATE_FAILED", "无法创建任务模板", err)
|
||
}
|
||
return s.Get(ctx, item.ID)
|
||
}
|
||
|
||
func (s *TaskTemplateService) Update(ctx context.Context, id uint, input TaskTemplateUpsertInput) (*TaskTemplateDetail, error) {
|
||
item, err := s.templates.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_GET_FAILED", "无法获取任务模板", err)
|
||
}
|
||
if item == nil {
|
||
return nil, apperror.New(404, "TASK_TEMPLATE_NOT_FOUND", "任务模板不存在", nil)
|
||
}
|
||
payloadJSON, err := json.Marshal(input.Payload)
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_ENCODE_FAILED", "无法序列化模板参数", err)
|
||
}
|
||
if strings.TrimSpace(input.Name) != item.Name {
|
||
dup, err := s.templates.FindByName(ctx, strings.TrimSpace(input.Name))
|
||
if err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_LOOKUP_FAILED", "无法校验模板名", err)
|
||
}
|
||
if dup != nil && dup.ID != id {
|
||
return nil, apperror.Conflict("TASK_TEMPLATE_NAME_EXISTS", "模板名称已存在", nil)
|
||
}
|
||
}
|
||
item.Name = strings.TrimSpace(input.Name)
|
||
item.Description = strings.TrimSpace(input.Description)
|
||
item.TaskType = strings.TrimSpace(input.Payload.Type)
|
||
item.Payload = string(payloadJSON)
|
||
if err := s.templates.Update(ctx, item); err != nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_UPDATE_FAILED", "无法更新任务模板", err)
|
||
}
|
||
return s.Get(ctx, item.ID)
|
||
}
|
||
|
||
func (s *TaskTemplateService) Delete(ctx context.Context, id uint) error {
|
||
item, err := s.templates.FindByID(ctx, id)
|
||
if err != nil {
|
||
return apperror.Internal("TASK_TEMPLATE_GET_FAILED", "无法获取任务模板", err)
|
||
}
|
||
if item == nil {
|
||
return apperror.New(404, "TASK_TEMPLATE_NOT_FOUND", "任务模板不存在", nil)
|
||
}
|
||
return s.templates.Delete(ctx, id)
|
||
}
|
||
|
||
// Apply 从模板批量创建任务。best-effort:单个失败不影响其他。
|
||
// 每个 Variables 条目按 name 覆盖任务名;其他字段(sourcePath/dbHost/dbName/tags/nodeId)非空则覆盖模板对应字段。
|
||
func (s *TaskTemplateService) Apply(ctx context.Context, id uint, input TaskTemplateApplyInput) ([]TaskTemplateApplyResult, error) {
|
||
template, err := s.Get(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if s.tasks == nil {
|
||
return nil, apperror.Internal("TASK_TEMPLATE_APPLY_UNAVAILABLE", "任务创建服务未注入", nil)
|
||
}
|
||
results := make([]TaskTemplateApplyResult, 0, len(input.Variables))
|
||
for _, v := range input.Variables {
|
||
payload := mergeVariables(template.Payload, v)
|
||
detail, createErr := s.tasks.Create(ctx, payload)
|
||
result := TaskTemplateApplyResult{Name: v.Name}
|
||
if createErr != nil {
|
||
result.Success = false
|
||
if appErr, ok := createErr.(*apperror.AppError); ok {
|
||
result.Error = appErr.Message
|
||
} else {
|
||
result.Error = createErr.Error()
|
||
}
|
||
} else {
|
||
result.Success = true
|
||
result.TaskID = detail.ID
|
||
}
|
||
results = append(results, result)
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
// mergeVariables 把 Variables 覆盖到模板 Payload 上。返回一个新的 Input(不污染模板)。
|
||
func mergeVariables(base BackupTaskUpsertInput, v TaskTemplateVariables) BackupTaskUpsertInput {
|
||
out := base
|
||
out.Name = strings.TrimSpace(v.Name)
|
||
if strings.TrimSpace(v.SourcePath) != "" {
|
||
out.SourcePath = strings.TrimSpace(v.SourcePath)
|
||
}
|
||
if len(v.SourcePaths) > 0 {
|
||
out.SourcePaths = v.SourcePaths
|
||
}
|
||
if strings.TrimSpace(v.DBHost) != "" {
|
||
out.DBHost = strings.TrimSpace(v.DBHost)
|
||
}
|
||
if strings.TrimSpace(v.DBName) != "" {
|
||
out.DBName = strings.TrimSpace(v.DBName)
|
||
}
|
||
if strings.TrimSpace(v.Tags) != "" {
|
||
out.Tags = strings.TrimSpace(v.Tags)
|
||
}
|
||
if v.NodeID != nil {
|
||
out.NodeID = *v.NodeID
|
||
}
|
||
return out
|
||
}
|
||
|
||
func toTemplateSummary(item *model.TaskTemplate) TaskTemplateSummary {
|
||
return TaskTemplateSummary{
|
||
ID: item.ID,
|
||
Name: item.Name,
|
||
Description: item.Description,
|
||
TaskType: item.TaskType,
|
||
CreatedBy: item.CreatedBy,
|
||
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||
}
|
||
}
|
||
|
||
// 确保未使用告警
|
||
var _ = fmt.Sprintf
|