功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)

* 功能: 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 的误判
This commit is contained in:
Wu Qing
2026-04-20 13:04:13 +08:00
committed by GitHub
parent 726c5e134b
commit f7596bd319
130 changed files with 14184 additions and 382 deletions

View File

@@ -20,6 +20,19 @@ const (
// Payload: {"path": "/var/log"}
// Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]}
AgentCommandTypeListDir = "list_dir"
// AgentCommandTypeRestoreRecord 在 Agent 节点上恢复指定备份记录
// Payload: {"restoreRecordId": 789}
// Agent 拉 /api/agent/restores/:id/spec 获取完整规格后执行恢复
AgentCommandTypeRestoreRecord = "restore_record"
// AgentCommandTypeDiscoverDB 在 Agent 节点上发现数据库列表
// Payload: {"type": "mysql", "host": "...", "port": 3306, "user": "...", "password": "..."}
// Result: {"databases": ["db1", "db2"]}
AgentCommandTypeDiscoverDB = "discover_db"
// AgentCommandTypeDeleteStorageObject 在 Agent 节点上删除指定存储对象
// Payload: {"targetType": "local_disk", "targetConfig": {...}, "storagePath": "tasks/1/x.tar.gz"}
// 用于跨节点 local_disk 场景Master 删记录时请求 Agent 清理其本地备份文件。
// Agent 需具备对应存储 provider 的执行能力。best-effort失败仅影响 Agent 侧文件残留。
AgentCommandTypeDeleteStorageObject = "delete_storage_object"
)
// AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。

View File

@@ -0,0 +1,24 @@
package model
import "time"
// ApiKey 用于 CI/CD、监控脚本等非交互式场景通过 HTTP API 访问 BackupX。
// 明文 Key 仅在创建时返回一次,数据库存储 SHA-256 哈希。
// 认证中间件:当 Authorization: Bearer 值以 "bax_" 前缀开头时走 API Key 验证。
type ApiKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Role string `gorm:"size:32;not null;default:viewer" json:"role"`
KeyHash string `gorm:"column:key_hash;size:128;uniqueIndex;not null" json:"-"`
Prefix string `gorm:"size:32;not null" json:"prefix"`
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt,omitempty"`
Disabled bool `gorm:"not null;default:false" json:"disabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ApiKey) TableName() string {
return "api_keys"
}

View File

@@ -14,6 +14,9 @@ type BackupRecord struct {
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
// NodeID 执行该次备份的节点0 = 本机 Master。用于集群中识别 local_disk 类型
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
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"`

View File

@@ -46,6 +46,25 @@ type BackupTask struct {
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
// 验证(恢复演练)配置 — 定期自动校验备份可恢复性
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
// SLA 配置 — RPO期望最长未备份间隔与告警阈值
SLAHoursRPO int `gorm:"column:sla_hours_rpo;not null;default:0" json:"slaHoursRpo"`
AlertOnConsecutiveFails int `gorm:"column:alert_on_consecutive_fails;not null;default:1" json:"alertOnConsecutiveFails"`
// ReplicationTargetIDs 备份复制目标存储 ID 列表CSV
// 备份完成后系统将自动把成果从任务主存储StorageTargets 的第一个)复制到这些目标。
// 满足 3-2-1 规则:至少 2 份副本,且至少 1 份异地(不同 provider/region
ReplicationTargetIDs string `gorm:"column:replication_target_ids;size:500" json:"replicationTargetIds"`
// MaintenanceWindows 允许执行备份的时段(格式详见 backup/window.go
// 空 = 不限制。非空时调度器在非窗口跳过,手动执行返回友好错误。
MaintenanceWindows string `gorm:"column:maintenance_windows;size:500" json:"maintenanceWindows"`
// DependsOnTaskIDs 依赖的上游任务 ID 列表CSV
// 语义:上游任务成功后自动触发本任务,形成工作流(如 DB 备份完成 → 归档压缩)。
// 调度器继续按本任务自己的 cron 触发,仅"自动触发"路径响应依赖完成事件。
// 循环依赖检查在 service 层完成,避免配置阶段即出错。
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -23,8 +23,14 @@ type Node struct {
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// MaxConcurrent 该节点允许的最大并发任务数0=不限制,沿用全局 cfg.Backup.MaxConcurrent
// 用于大集群中限制单节点资源占用:例如小内存 Agent 节点可配 1避免多个大备份同时跑挤爆。
MaxConcurrent int `gorm:"column:max_concurrent;not null;default:0" json:"maxConcurrent"`
// BandwidthLimit 该节点上传带宽上限rclone 可识别格式10M / 1G / 0=不限)。
// 对集群感知的上传场景有效Master 本地与 Agent 运行时均会应用)。
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Node) TableName() string {

View File

@@ -2,6 +2,26 @@ package model
import "time"
// 通知事件类型(企业级事件总线)。
// 任一 Notification 可订阅多个事件EventTypes 字段存 CSV。
// 空 EventTypes + OnSuccess/OnFailure=true 时沿用旧语义(仅备份成功/失败)。
const (
NotificationEventBackupSuccess = "backup_success"
NotificationEventBackupFailed = "backup_failed"
NotificationEventRestoreSuccess = "restore_success"
NotificationEventRestoreFailed = "restore_failed"
NotificationEventVerifyFailed = "verify_failed"
NotificationEventSLAViolation = "sla_violation"
// NotificationEventStorageUnhealthy 存储目标连接失败(后台健康扫描触发)。
NotificationEventStorageUnhealthy = "storage_unhealthy"
// NotificationEventReplicationFailed 备份复制失败。
NotificationEventReplicationFailed = "replication_failed"
// NotificationEventAgentOutdated Agent 版本落后 Master建议升级。
NotificationEventAgentOutdated = "agent_outdated"
// NotificationEventStorageCapacity 存储目标使用率超过预警阈值85%)。
NotificationEventStorageCapacity = "storage_capacity_warning"
)
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
@@ -10,8 +30,11 @@ type Notification struct {
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// EventTypes 逗号分隔,订阅的事件类型。
// 空 = 仅监听备份成功/失败(兼容旧配置);非空则严格按订阅触发。
EventTypes string `gorm:"column:event_types;size:500" json:"eventTypes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Notification) TableName() string {

View File

@@ -0,0 +1,44 @@
package model
import "time"
// ReplicationRecord 记录一次备份复制的执行。
// 触发方式:
// - 自动:备份成功后,根据 task.ReplicationTargetIDs 自动派发
// - 手动:从备份记录详情页手动触发
//
// 核心语义:把源存储上的备份对象 mirror 到目标存储,保留 StoragePath。
// 3-2-1 规则核心:每份备份至少存在于两个独立存储目标,且至少一份异地。
const (
ReplicationStatusRunning = "running"
ReplicationStatusSuccess = "success"
ReplicationStatusFailed = "failed"
)
type ReplicationRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
// SourceTargetID 源存储目标(备份已存在于此)
SourceTargetID uint `gorm:"column:source_target_id;index;not null" json:"sourceTargetId"`
SourceTarget StorageTarget `gorm:"foreignKey:SourceTargetID;references:ID" json:"sourceTarget,omitempty"`
// DestTargetID 目标存储(复制过去)
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ReplicationRecord) TableName() string {
return "replication_records"
}

View File

@@ -0,0 +1,33 @@
package model
import "time"
// RestoreRecord 代表一次恢复执行,用于审计、实时日志与列表页。
// 每次从 BackupRecord 触发恢复都会产生独立 RestoreRecord与 BackupRecord 一对多。
const (
RestoreRecordStatusRunning = "running"
RestoreRecordStatusSuccess = "success"
RestoreRecordStatusFailed = "failed"
)
type RestoreRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (RestoreRecord) TableName() string {
return "restore_records"
}

View File

@@ -14,8 +14,12 @@ type StorageTarget struct {
LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"`
LastTestStatus string `gorm:"column:last_test_status;size:32;not null;default:'unknown'" json:"lastTestStatus"`
LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// QuotaBytes 软限额字节。0 = 不限制。
// 备份执行前检查:该目标上已累计字节数 + 本次文件大小 > QuotaBytes 时拒绝上传。
// 比容量预警85% 通知)更严格,作为企业治理"防超用"的硬性闸门。
QuotaBytes int64 `gorm:"column:quota_bytes;not null;default:0" json:"quotaBytes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StorageTarget) TableName() string {

View File

@@ -0,0 +1,27 @@
package model
import "time"
// TaskTemplate 是批量创建任务的模板。
// 用途大规模场景100+ 任务)下保存一份参数预设,
// 再通过"应用模板"接口一次性创建多个任务(变量替换 Name/SourcePath 等)。
//
// 参数存 JSONPayload结构与 service.BackupTaskUpsertInput 基本一致,
// 仅以下字段在应用时可被变量覆盖:
// - name
// - sourcePath / sourcePaths 中的 {{.Host}} / {{.Env}} 等占位符
type TaskTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
// Payload JSON存完整 BackupTaskUpsertInput 的序列化
Payload string `gorm:"type:text;not null" json:"payload"`
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (TaskTemplate) TableName() string {
return "task_templates"
}

View File

@@ -2,6 +2,25 @@ package model
import "time"
// 用户角色常量。RBAC 策略:
// - admin系统全权创建用户、管理 API Key、删除数据、改设置
// - operator日常运维创建/编辑/执行任务、触发恢复与验证、管理存储目标与通知)
// - viewer只读查看仪表盘、任务、记录、日志不能触发或改变状态
const (
UserRoleAdmin = "admin"
UserRoleOperator = "operator"
UserRoleViewer = "viewer"
)
// IsValidRole 校验角色字符串合法。
func IsValidRole(role string) bool {
switch role {
case UserRoleAdmin, UserRoleOperator, UserRoleViewer:
return true
}
return false
}
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
@@ -9,8 +28,10 @@ type User struct {
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Disabled 禁用账号(不删除保留审计)。禁用后无法登录。
Disabled bool `gorm:"not null;default:false" json:"disabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (User) TableName() string {

View File

@@ -0,0 +1,43 @@
package model
import "time"
// VerificationRecord 记录一次备份验证(或演练)的执行。
// 验证目标:从指定 BackupRecord 读取归档 → 在沙箱内执行只读校验
// (解压/格式检查/完整性校验),不改动源数据。
const (
VerificationRecordStatusRunning = "running"
VerificationRecordStatusSuccess = "success"
VerificationRecordStatusFailed = "failed"
// VerificationModeQuick 仅做格式与完整性校验tar header、SHA-256、DB dump 头)。
// 耗时短,不占用目标系统资源,适合每日调度。
VerificationModeQuick = "quick"
// VerificationModeDeep 真正恢复到隔离沙箱(临时库或解压目录),验证可读。
// 耗时较长,适合每周/每月。当前版本保留接口不实现。
VerificationModeDeep = "deep"
)
type VerificationRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Mode string `gorm:"size:20;not null;default:'quick'" json:"mode"`
Status string `gorm:"size:20;index;not null" json:"status"`
Summary string `gorm:"size:500" json:"summary"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (VerificationRecord) TableName() string {
return "verification_records"
}