功能: 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

@@ -14,12 +14,13 @@ import (
// AgentHandler 实现 Agent 调用 Master 的 HTTP API。
// 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。
type AgentHandler struct {
agentService *service.AgentService
nodeService *service.NodeService
agentService *service.AgentService
nodeService *service.NodeService
restoreService *service.RestoreService
}
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService) *AgentHandler {
return &AgentHandler{agentService: agentService, nodeService: nodeService}
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService, restoreService *service.RestoreService) *AgentHandler {
return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
}
// extractToken 从请求头或 JSON body 中提取 Agent Token。
@@ -155,6 +156,58 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
response.Success(c, gin.H{"status": "ok"})
}
// GetRestoreSpec Agent 拉取恢复规格。
func (h *AgentHandler) GetRestoreSpec(c *gin.Context) {
if h.restoreService == nil {
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
return
}
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
spec, err := h.restoreService.GetAgentRestoreSpec(c.Request.Context(), node, uint(id))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, spec)
}
// UpdateRestore Agent 上报恢复记录的状态/日志。
func (h *AgentHandler) UpdateRestore(c *gin.Context) {
if h.restoreService == nil {
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
return
}
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
var input service.AgentRestoreUpdate
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
if err := h.restoreService.UpdateAgentRestore(c.Request.Context(), node, uint(id), input); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"status": "ok"})
}
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
func (h *AgentHandler) Self(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))

View File

@@ -0,0 +1,93 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// ApiKeyHandler 管理 API Keyadmin 专属)。
type ApiKeyHandler struct {
service *service.ApiKeyService
auditService *service.AuditService
}
func NewApiKeyHandler(apiKeyService *service.ApiKeyService, auditService *service.AuditService) *ApiKeyHandler {
return &ApiKeyHandler{service: apiKeyService, auditService: auditService}
}
func (h *ApiKeyHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *ApiKeyHandler) Create(c *gin.Context) {
var input service.ApiKeyCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "API Key 参数不合法", err))
return
}
creator := ""
if username, exists := c.Get(contextUsernameKey); exists {
if v, ok := username.(string); ok {
creator = v
}
}
result, err := h.service.Create(c.Request.Context(), creator, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "api_key", "create", "api_key", fmt.Sprintf("%d", result.ApiKey.ID), result.ApiKey.Name,
fmt.Sprintf("创建 API Key: %s (角色: %s)", result.ApiKey.Name, result.ApiKey.Role))
response.Success(c, result)
}
func (h *ApiKeyHandler) Revoke(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Revoke(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "api_key", "revoke", "api_key", fmt.Sprintf("%d", id), "",
fmt.Sprintf("撤销 API Key (ID: %d)", id))
response.Success(c, gin.H{"revoked": true})
}
func (h *ApiKeyHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Disabled bool `json:"disabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "参数不合法", err))
return
}
if err := h.service.ToggleDisabled(c.Request.Context(), id, input.Disabled); err != nil {
response.Error(c, err)
return
}
action := "enable"
label := "启用"
if input.Disabled {
action = "disable"
label = "停用"
}
recordAudit(c, h.auditService, "api_key", action, "api_key", fmt.Sprintf("%d", id), "",
fmt.Sprintf("%s API Key (ID: %d)", label, id))
response.Success(c, gin.H{"disabled": input.Disabled})
}

View File

@@ -1,11 +1,18 @@
package http
import (
"encoding/csv"
"fmt"
stdhttp "net/http"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/repository"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
@@ -17,24 +24,97 @@ func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
return &AuditHandler{auditService: auditService}
}
// List 多字段筛选分页查询审计日志。
// 支持参数category, action, username, targetId, keyword, dateFrom, dateTo, limit, offset。
// 向后兼容:若仅传 category + limit + offset行为与旧版一致。
func (h *AuditHandler) List(c *gin.Context) {
category := strings.TrimSpace(c.Query("category"))
limit := 50
offset := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
opts, err := parseAuditFilter(c)
if err != nil {
response.Error(c, err)
return
}
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
offset = parsed
}
}
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
result, err := h.auditService.ListAdvanced(c.Request.Context(), opts)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
// Export 导出 CSV。同筛选参数最多 10000 行。
// 文件名带时间戳避免浏览器缓存覆盖。
func (h *AuditHandler) Export(c *gin.Context) {
opts, err := parseAuditFilter(c)
if err != nil {
response.Error(c, err)
return
}
// 导出不分页:覆盖掉 List 的默认 limit
opts.Limit = 0
opts.Offset = 0
items, err := h.auditService.ExportAll(c.Request.Context(), opts)
if err != nil {
response.Error(c, err)
return
}
filename := fmt.Sprintf("backupx-audit-%s.csv", time.Now().UTC().Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// UTF-8 BOM 让 Excel 正确识别中文
_, _ = c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Writer)
_ = writer.Write([]string{"时间", "用户", "类别", "动作", "目标类型", "目标 ID", "目标名", "详情", "客户端 IP"})
for _, item := range items {
_ = writer.Write([]string{
item.CreatedAt.UTC().Format(time.RFC3339),
item.Username,
item.Category,
item.Action,
item.TargetType,
item.TargetID,
item.TargetName,
item.Detail,
item.ClientIP,
})
}
writer.Flush()
if err := writer.Error(); err != nil {
c.Writer.WriteHeader(stdhttp.StatusInternalServerError)
}
}
// parseAuditFilter 解析查询参数为 repository 选项。
func parseAuditFilter(c *gin.Context) (repository.AuditLogListOptions, error) {
opts := repository.AuditLogListOptions{
Category: strings.TrimSpace(c.Query("category")),
Action: strings.TrimSpace(c.Query("action")),
Username: strings.TrimSpace(c.Query("username")),
TargetID: strings.TrimSpace(c.Query("targetId")),
Keyword: strings.TrimSpace(c.Query("keyword")),
}
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
opts.Limit = n
}
}
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
opts.Offset = n
}
}
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
opts.DateFrom = &parsed
}
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
opts.DateTo = &parsed
}
return opts, nil
}

View File

@@ -16,12 +16,13 @@ import (
)
type BackupRecordHandler struct {
service *service.BackupRecordService
auditService *service.AuditService
service *service.BackupRecordService
restoreService *service.RestoreService
auditService *service.AuditService
}
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, auditService: auditService}
func NewBackupRecordHandler(recordService *service.BackupRecordService, restoreService *service.RestoreService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, restoreService: restoreService, auditService: auditService}
}
func (h *BackupRecordHandler) List(c *gin.Context) {
@@ -121,18 +122,29 @@ func (h *BackupRecordHandler) Download(c *gin.Context) {
_, _ = io.Copy(c.Writer, result.Reader)
}
// Restore 启动一次异步恢复并返回 restoreRecordId实际执行路由由 RestoreService
// 根据 task.NodeID 决定(本地 Master or 远程 Agent
func (h *BackupRecordHandler) Restore(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Restore(c.Request.Context(), id); err != nil {
if h.restoreService == nil {
response.Error(c, apperror.Internal("RESTORE_SERVICE_DISABLED", "恢复服务未启用", nil))
return
}
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
fmt.Sprintf("恢复备份记录 (ID: %d)", id))
response.Success(c, gin.H{"restored": true})
fmt.Sprintf("启动恢复 (备份记录 ID: %d, 恢复记录 ID: %d)", id, detail.ID))
response.Success(c, detail)
}
func (h *BackupRecordHandler) Delete(c *gin.Context) {

View File

@@ -3,6 +3,7 @@ package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
@@ -30,3 +31,37 @@ func (h *BackupRunHandler) Run(c *gin.Context) {
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
response.Success(c, record)
}
// BatchRun 批量触发备份任务。best-effort单个失败不影响其他。
// Body: {"ids": [1,2,3]}
func (h *BackupRunHandler) BatchRun(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量执行参数不合法", err))
return
}
results := make([]service.BatchResult, 0, len(input.IDs))
succ := 0
for _, id := range input.IDs {
if id == 0 {
continue
}
_, err := h.service.RunTaskByID(c.Request.Context(), id)
item := service.BatchResult{ID: id, Success: err == nil}
if err != nil {
if appErr, ok := err.(*apperror.AppError); ok {
item.Error = appErr.Message
} else {
item.Error = err.Error()
}
} else {
succ++
}
results = append(results, item)
}
recordAudit(c, h.auditService, "backup_task", "batch_run", "backup_task", "", "",
fmt.Sprintf("批量触发备份 %d/%d", succ, len(results)))
response.Success(c, results)
}

View File

@@ -40,6 +40,16 @@ func (h *BackupTaskHandler) List(c *gin.Context) {
response.Success(c, items)
}
// ListTags 返回系统内所有任务用过的唯一标签列表,供前端标签选择器的建议词。
func (h *BackupTaskHandler) ListTags(c *gin.Context) {
tags, err := h.service.ListTags(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, tags)
}
func (h *BackupTaskHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
@@ -106,6 +116,55 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true})
}
// BatchToggle / BatchDelete 批量操作。
// Body: {"ids": [1,2,3], "enabled": true} (enabled 仅 toggle 用)
func (h *BackupTaskHandler) BatchToggle(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量操作参数不合法", err))
return
}
results := h.service.BatchToggle(c.Request.Context(), input.IDs, input.Enabled)
succ := 0
for _, r := range results {
if r.Success {
succ++
}
}
action := "batch_enable"
label := "启用"
if !input.Enabled {
action = "batch_disable"
label = "停用"
}
recordAudit(c, h.auditService, "backup_task", action, "backup_task", "", "",
fmt.Sprintf("批量%s %d/%d 个任务", label, succ, len(results)))
response.Success(c, results)
}
func (h *BackupTaskHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量删除参数不合法", err))
return
}
results := h.service.BatchDeleteTasks(c.Request.Context(), input.IDs)
succ := 0
for _, r := range results {
if r.Success {
succ++
}
}
recordAudit(c, h.auditService, "backup_task", "batch_delete", "backup_task", "", "",
fmt.Sprintf("批量删除 %d/%d 个任务", succ, len(results)))
response.Success(c, results)
}
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {

View File

@@ -1,3 +1,9 @@
package http
const contextUserSubjectKey = "userSubject"
const (
contextUserSubjectKey = "userSubject"
contextUserRoleKey = "userRole"
contextUsernameKey = "username"
// contextAuthSubjectKey 标识认证主体来源user | api_key便于审计追踪。
contextAuthSubjectKey = "authSubject"
)

View File

@@ -27,6 +27,58 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
response.Success(c, payload)
}
// SLA 返回所有启用任务的 SLA 合规视图。用于 Dashboard 企业合规卡片。
func (h *DashboardHandler) SLA(c *gin.Context) {
payload, err := h.service.SLACompliance(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// Cluster 返回集群节点概览(在线/离线/过期 Agent 等),用于 Dashboard 卡片。
func (h *DashboardHandler) Cluster(c *gin.Context) {
payload, err := h.service.ClusterOverview(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// NodePerformance 返回各节点近 N 天的执行表现(成功率/字节数/平均耗时)。
func (h *DashboardHandler) NodePerformance(c *gin.Context) {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
payload, err := h.service.NodePerformance(c.Request.Context(), days)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// Breakdown 返回按类型/状态/节点/存储分组的统计。
func (h *DashboardHandler) Breakdown(c *gin.Context) {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
payload, err := h.service.Breakdown(c.Request.Context(), days)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *DashboardHandler) Timeline(c *gin.Context) {
days := 30
if value := strings.TrimSpace(c.Query("days")); value != "" {

View File

@@ -0,0 +1,81 @@
package http
import (
"encoding/json"
"fmt"
"io"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// EventsHandler 实时事件推送SSE
// 前端通过 EventSource 订阅 /api/events/stream实时接收系统事件
// 用于 Dashboard 免刷新更新 / 桌面 Toast / 实时告警。
type EventsHandler struct {
broadcaster *service.EventBroadcaster
}
func NewEventsHandler(broadcaster *service.EventBroadcaster) *EventsHandler {
return &EventsHandler{broadcaster: broadcaster}
}
// Stream SSE 长连接。JWT/API Key 中间件之后。
// 心跳:每 25s 发一条 comment 行(: keepalive保持连接不被代理断开。
func (h *EventsHandler) Stream(c *gin.Context) {
if h.broadcaster == nil {
response.Error(c, apperror.Internal("EVENTS_DISABLED", "事件广播器未启用", nil))
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no") // 禁用 nginx 缓冲
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("EVENTS_STREAM_UNSUPPORTED", "当前连接不支持 SSE", nil))
return
}
// 首先发送一次 hello 让客户端确认连通
_, _ = fmt.Fprintf(c.Writer, ": connected %d\n\n", time.Now().Unix())
flusher.Flush()
ch, cancel := h.broadcaster.Subscribe(32)
defer cancel()
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case <-heartbeat.C:
if _, err := fmt.Fprintf(c.Writer, ": heartbeat %d\n\n", time.Now().Unix()); err != nil {
return
}
flusher.Flush()
case envelope, ok := <-ch:
if !ok {
return
}
if err := writeEventEnvelope(c.Writer, envelope); err != nil {
return
}
flusher.Flush()
}
}
}
func writeEventEnvelope(writer io.Writer, envelope service.EventEnvelope) error {
data, err := json.Marshal(envelope)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: %s\ndata: %s\n\n", envelope.Type, data)
return err
}

View File

@@ -0,0 +1,75 @@
package http
import (
stdhttp "net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// HealthHandler 提供 K8s/Swarm 风格的健康检查端点。
//
// - /health liveness 探针。进程存活即 200不检查任何依赖
// - /ready readiness 探针。检查数据库连通,不通则返回 503。
//
// 两者均为公开端点(无认证中间件),供外部编排系统探测。
// 输出最少信息,避免泄露内部结构。
type HealthHandler struct {
db *gorm.DB
startedAt time.Time
version string
}
func NewHealthHandler(db *gorm.DB, version string) *HealthHandler {
return &HealthHandler{db: db, startedAt: time.Now().UTC(), version: version}
}
// Live 用于 liveness只要进程能响应就返回 200。
func (h *HealthHandler) Live(c *gin.Context) {
c.JSON(stdhttp.StatusOK, gin.H{
"status": "live",
"version": h.version,
"uptime": int(time.Since(h.startedAt).Seconds()),
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// Ready 用于 readiness依赖数据库不可用时返回 503。
// 新实例启动或数据库短暂失联时,编排系统据此停止转发流量。
func (h *HealthHandler) Ready(c *gin.Context) {
checks := map[string]string{}
overallOK := true
if h.db != nil {
sqlDB, err := h.db.DB()
if err != nil {
checks["database"] = "error: " + err.Error()
overallOK = false
} else {
ctx, cancel := c.Request.Context(), func() {}
_ = cancel
if err := sqlDB.PingContext(ctx); err != nil {
checks["database"] = "ping failed: " + err.Error()
overallOK = false
} else {
checks["database"] = "ok"
}
}
} else {
checks["database"] = "not configured"
overallOK = false
}
status := stdhttp.StatusOK
state := "ready"
if !overallOK {
status = stdhttp.StatusServiceUnavailable
state = "not_ready"
}
c.JSON(status, gin.H{
"status": state,
"version": h.version,
"uptime": int(time.Since(h.startedAt).Seconds()),
"checks": checks,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -1,6 +1,7 @@
package http
import (
"context"
stdhttp "net/http"
"strings"
@@ -26,28 +27,94 @@ func CORSMiddleware() gin.HandlerFunc {
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
// ApiKeyAuthenticator 抽象 API Key 验证能力,避免 middleware 直接依赖 service 包。
// 实现方service.ApiKeyService。未注入时 AuthMiddleware 仍然支持 JWT。
type ApiKeyAuthenticator interface {
Authenticate(ctx context.Context, rawKey string) (subject string, role string, err error)
}
// AuthMiddleware 支持两种认证方式:
// - JWT (Authorization: Bearer <jwt>):交互式用户
// - API Key (Authorization: Bearer bax_xxx 或 X-Api-Key: bax_xxx):第三方脚本
//
// JWT 会在 context 中写入 userSubject / userRole / username
// API Key 会写入 authSubject=api_key:<id> / userRole=<key role>。
func AuthMiddleware(jwtManager *security.JWTManager, apiKeyAuth ApiKeyAuthenticator) gin.HandlerFunc {
return func(c *gin.Context) {
header := strings.TrimSpace(c.GetHeader("Authorization"))
if !strings.HasPrefix(header, "Bearer ") {
rawToken := extractAuthToken(c)
if rawToken == "" {
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
c.Abort()
return
}
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
claims, err := jwtManager.Parse(tokenString)
if apiKeyAuth != nil && strings.HasPrefix(rawToken, "bax_") {
subject, role, err := apiKeyAuth.Authenticate(c.Request.Context(), rawToken)
if err != nil {
response.Error(c, err)
c.Abort()
return
}
c.Set(contextAuthSubjectKey, subject)
c.Set(contextUserRoleKey, role)
c.Set(contextUserSubjectKey, subject)
c.Set(contextUsernameKey, subject)
c.Next()
return
}
claims, err := jwtManager.Parse(rawToken)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
c.Abort()
return
}
c.Set(contextUserSubjectKey, claims.Subject)
c.Set(contextUserRoleKey, claims.Role)
c.Set(contextUsernameKey, claims.Username)
c.Set(contextAuthSubjectKey, "user:"+claims.Subject)
c.Next()
}
}
// extractAuthToken 从 Authorization: Bearer 或 X-Api-Key 中提取原始 token。
func extractAuthToken(c *gin.Context) string {
header := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(header, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
}
if key := strings.TrimSpace(c.GetHeader("X-Api-Key")); key != "" {
return key
}
return ""
}
// RequireRole 仅放行指定角色,否则返回 403。
// 必须用在 AuthMiddleware 之后。viewer 只读保护、admin 管理端都靠它。
func RequireRole(roles ...string) gin.HandlerFunc {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[strings.ToLower(r)] = true
}
return func(c *gin.Context) {
role, _ := c.Get(contextUserRoleKey)
roleStr := ""
if v, ok := role.(string); ok {
roleStr = strings.ToLower(v)
}
if !allowed[roleStr] {
response.Error(c, apperror.New(403, "AUTH_FORBIDDEN", "当前角色无权执行此操作", nil))
c.Abort()
return
}
c.Next()
}
}
// RequireNotViewer 是 RequireRole(admin, operator) 的快捷方式,
// 用于任何"写入/变更"类端点,禁止 viewer 触发。
func RequireNotViewer() gin.HandlerFunc {
return RequireRole("admin", "operator")
}
func ClientKey(c *gin.Context) string {
ip := strings.TrimSpace(c.ClientIP())
if ip == "" {

View File

@@ -0,0 +1,128 @@
package http
import (
"fmt"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// ReplicationHandler 管理备份复制记录列表 + 手动触发。
type ReplicationHandler struct {
service *service.ReplicationService
auditService *service.AuditService
}
func NewReplicationHandler(replicationService *service.ReplicationService, auditService *service.AuditService) *ReplicationHandler {
return &ReplicationHandler{service: replicationService, auditService: auditService}
}
// TriggerByRecord 手动触发:从备份记录复制到指定目标存储。
// Body: {"destTargetId": 12}
func (h *ReplicationHandler) TriggerByRecord(c *gin.Context) {
recordID, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
DestTargetID uint `json:"destTargetId" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("REPLICATION_INVALID", "复制参数不合法", err))
return
}
triggeredBy := ""
if subject, exists := c.Get(contextUsernameKey); exists {
if v, ok := subject.(string); ok {
triggeredBy = v
}
}
if triggeredBy == "" {
triggeredBy = "manual"
}
result, err := h.service.Start(c.Request.Context(), recordID, input.DestTargetID, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "replication", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
fmt.Sprintf("手动触发复制(备份记录 #%d → 存储 #%d, 复制记录 #%d", recordID, input.DestTargetID, result.ID))
response.Success(c, result)
}
func (h *ReplicationHandler) List(c *gin.Context) {
filter, err := buildReplicationFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *ReplicationHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func buildReplicationFilter(c *gin.Context) (service.ReplicationRecordListInput, error) {
var filter service.ReplicationRecordListInput
if v := strings.TrimSpace(c.Query("taskId")); v != "" {
parsed, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "taskId 不合法", err)
}
id := uint(parsed)
filter.TaskID = &id
}
if v := strings.TrimSpace(c.Query("backupRecordId")); v != "" {
parsed, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "backupRecordId 不合法", err)
}
id := uint(parsed)
filter.BackupRecordID = &id
}
if v := strings.TrimSpace(c.Query("destTargetId")); v != "" {
parsed, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "destTargetId 不合法", err)
}
id := uint(parsed)
filter.DestTargetID = &id
}
filter.Status = strings.TrimSpace(c.Query("status"))
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateFrom 必须为 RFC3339", err)
}
filter.DateFrom = &parsed
}
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateTo 必须为 RFC3339", err)
}
filter.DateTo = &parsed
}
return filter, nil
}

View File

@@ -0,0 +1,162 @@
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// RestoreRecordHandler 提供恢复记录列表/详情/实时日志端点。
// 创建恢复由 BackupRecordHandler.Restore 代理到 RestoreService.Start。
type RestoreRecordHandler struct {
service *service.RestoreService
auditService *service.AuditService
}
func NewRestoreRecordHandler(restoreService *service.RestoreService, auditService *service.AuditService) *RestoreRecordHandler {
return &RestoreRecordHandler{service: restoreService, auditService: auditService}
}
func (h *RestoreRecordHandler) List(c *gin.Context) {
filter, err := buildRestoreFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *RestoreRecordHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *RestoreRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("RESTORE_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func buildRestoreFilter(c *gin.Context) (service.RestoreRecordListInput, error) {
var filter service.RestoreRecordListInput
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
parsed, err := strconv.ParseUint(taskIDValue, 10, 32)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "taskId 不合法", err)
}
v := uint(parsed)
filter.TaskID = &v
}
if backupValue := strings.TrimSpace(c.Query("backupRecordId")); backupValue != "" {
parsed, err := strconv.ParseUint(backupValue, 10, 32)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
}
v := uint(parsed)
filter.BackupRecordID = &v
}
if nodeValue := strings.TrimSpace(c.Query("nodeId")); nodeValue != "" {
parsed, err := strconv.ParseUint(nodeValue, 10, 32)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "nodeId 不合法", err)
}
v := uint(parsed)
filter.NodeID = &v
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeRestoreSSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}

View File

@@ -13,6 +13,7 @@ import (
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type RouterDependencies struct {
@@ -28,6 +29,15 @@ type RouterDependencies struct {
BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService
RestoreService *service.RestoreService
VerificationService *service.VerificationService
ReplicationService *service.ReplicationService
TaskTemplateService *service.TaskTemplateService
TaskExportService *service.TaskExportService
SearchService *service.SearchService
EventBroadcaster *service.EventBroadcaster
UserService *service.UserService
ApiKeyService *service.ApiKeyService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
SettingsService *service.SettingsService
@@ -40,6 +50,8 @@ type RouterDependencies struct {
SystemConfigRepo repository.SystemConfigRepository
InstallTokenService *service.InstallTokenService
MasterExternalURL string
// DB 注入给健康检查端点做 liveness/readiness 探测。
DB *gorm.DB
}
func NewRouter(deps RouterDependencies) *gin.Engine {
@@ -54,7 +66,19 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.RestoreService, deps.AuditService)
restoreRecordHandler := NewRestoreRecordHandler(deps.RestoreService, deps.AuditService)
verificationHandler := NewVerificationHandler(deps.VerificationService, deps.AuditService)
replicationHandler := NewReplicationHandler(deps.ReplicationService, deps.AuditService)
taskTemplateHandler := NewTaskTemplateHandler(deps.TaskTemplateService, deps.AuditService)
userHandler := NewUserHandler(deps.UserService, deps.AuditService)
apiKeyHandler := NewApiKeyHandler(deps.ApiKeyService, deps.AuditService)
// apiKeyAuth给 AuthMiddleware 注入 API Key 验证能力。
// 为 nil 时中间件仅支持 JWT不影响向后兼容。
var apiKeyAuth ApiKeyAuthenticator
if deps.ApiKeyService != nil {
apiKeyAuth = deps.ApiKeyService
}
notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
@@ -67,109 +91,207 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword)
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
}
system := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager))
system.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
system.GET("/info", systemHandler.Info)
system.GET("/update-check", systemHandler.CheckUpdate)
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager))
storageTargets.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
storageTargets.GET("", storageTargetHandler.List)
storageTargets.POST("", storageTargetHandler.Create)
storageTargets.POST("/test", storageTargetHandler.TestConnection)
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.POST("", RequireNotViewer(), storageTargetHandler.Create)
storageTargets.POST("/test", RequireNotViewer(), storageTargetHandler.TestConnection)
storageTargets.POST("/google-drive/auth-url", RequireNotViewer(), storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", RequireNotViewer(), storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
rcloneHandler := NewRcloneHandler()
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
// 参数路由
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.PUT("/:id", RequireNotViewer(), storageTargetHandler.Update)
storageTargets.DELETE("/:id", RequireNotViewer(), storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", RequireNotViewer(), storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", RequireNotViewer(), storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks")
backupTasks.Use(AuthMiddleware(deps.JWTManager))
backupTasks.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupTasks.GET("", backupTaskHandler.List)
backupTasks.GET("/tags", backupTaskHandler.ListTags)
backupTasks.GET("/:id", backupTaskHandler.Get)
backupTasks.POST("", backupTaskHandler.Create)
backupTasks.PUT("/:id", backupTaskHandler.Update)
backupTasks.DELETE("/:id", backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", backupRunHandler.Run)
backupTasks.POST("", RequireNotViewer(), backupTaskHandler.Create)
backupTasks.PUT("/:id", RequireNotViewer(), backupTaskHandler.Update)
backupTasks.DELETE("/:id", RequireNotViewer(), backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", RequireNotViewer(), backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", RequireNotViewer(), backupRunHandler.Run)
backupTasks.POST("/batch/toggle", RequireNotViewer(), backupTaskHandler.BatchToggle)
backupTasks.POST("/batch/delete", RequireNotViewer(), backupTaskHandler.BatchDelete)
backupTasks.POST("/batch/run", RequireNotViewer(), backupRunHandler.BatchRun)
// 任务配置导入/导出(集群迁移 & 灾备)
if deps.TaskExportService != nil {
taskExportHandler := NewTaskExportHandler(deps.TaskExportService, deps.AuditService)
backupTasks.GET("/export", taskExportHandler.Export)
backupTasks.POST("/import", RequireNotViewer(), taskExportHandler.Import)
}
if deps.VerificationService != nil {
backupTasks.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByTask)
}
backupRecords := api.Group("/backup/records")
backupRecords.Use(AuthMiddleware(deps.JWTManager))
backupRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupRecords.GET("", backupRecordHandler.List)
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
// 创建恢复仍然走 POST /backup/records/:id/restore以源备份记录为触发点
if deps.RestoreService != nil {
restoreRecords := api.Group("/restore/records")
restoreRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
restoreRecords.GET("", restoreRecordHandler.List)
restoreRecords.GET("/:id", restoreRecordHandler.Get)
restoreRecords.GET("/:id/logs/stream", restoreRecordHandler.StreamLogs)
}
// 备份复制记录3-2-1 规则)
if deps.ReplicationService != nil {
replicationRecords := api.Group("/replication/records")
replicationRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
replicationRecords.GET("", replicationHandler.List)
replicationRecords.GET("/:id", replicationHandler.Get)
backupRecords.POST("/:id/replicate", RequireNotViewer(), replicationHandler.TriggerByRecord)
}
// 任务模板(批量创建)
if deps.TaskTemplateService != nil {
templates := api.Group("/task-templates")
templates.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
templates.GET("", taskTemplateHandler.List)
templates.GET("/:id", taskTemplateHandler.Get)
templates.POST("", RequireNotViewer(), taskTemplateHandler.Create)
templates.PUT("/:id", RequireNotViewer(), taskTemplateHandler.Update)
templates.DELETE("/:id", RequireNotViewer(), taskTemplateHandler.Delete)
templates.POST("/:id/apply", RequireNotViewer(), taskTemplateHandler.Apply)
}
// 备份验证/演练记录
if deps.VerificationService != nil {
verifyRecords := api.Group("/verify/records")
verifyRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
verifyRecords.GET("", verificationHandler.List)
verifyRecords.GET("/:id", verificationHandler.Get)
verifyRecords.GET("/:id/logs/stream", verificationHandler.StreamLogs)
// 基于备份记录的验证入口:与 restore 对称
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
}
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager))
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats)
dashboard.GET("/timeline", dashboardHandler.Timeline)
dashboard.GET("/sla", dashboardHandler.SLA)
dashboard.GET("/cluster", dashboardHandler.Cluster)
dashboard.GET("/breakdown", dashboardHandler.Breakdown)
dashboard.GET("/node-performance", dashboardHandler.NodePerformance)
notifications := api.Group("/notifications")
notifications.Use(AuthMiddleware(deps.JWTManager))
notifications.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
notifications.GET("", notificationHandler.List)
notifications.GET("/:id", notificationHandler.Get)
notifications.POST("", notificationHandler.Create)
notifications.PUT("/:id", notificationHandler.Update)
notifications.DELETE("/:id", notificationHandler.Delete)
notifications.POST("/test", notificationHandler.Test)
notifications.POST("/:id/test", notificationHandler.TestSaved)
notifications.POST("", RequireNotViewer(), notificationHandler.Create)
notifications.PUT("/:id", RequireNotViewer(), notificationHandler.Update)
notifications.DELETE("/:id", RequireNotViewer(), notificationHandler.Delete)
notifications.POST("/test", RequireNotViewer(), notificationHandler.Test)
notifications.POST("/:id/test", RequireNotViewer(), notificationHandler.TestSaved)
settings := api.Group("/settings")
settings.Use(AuthMiddleware(deps.JWTManager))
settings.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
settings.GET("", settingsHandler.Get)
settings.PUT("", settingsHandler.Update)
settings.PUT("", RequireRole("admin"), settingsHandler.Update)
// 用户管理admin 专属)
if deps.UserService != nil {
users := api.Group("/users")
users.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.PUT("/:id", userHandler.Update)
users.DELETE("/:id", userHandler.Delete)
}
// API Key 管理admin 专属)
if deps.ApiKeyService != nil {
apiKeys := api.Group("/api-keys")
apiKeys.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
apiKeys.GET("", apiKeyHandler.List)
apiKeys.POST("", apiKeyHandler.Create)
apiKeys.PUT("/:id/toggle", apiKeyHandler.Toggle)
apiKeys.DELETE("/:id", apiKeyHandler.Revoke)
}
auditLogs := api.Group("/audit-logs")
auditLogs.Use(AuthMiddleware(deps.JWTManager))
auditLogs.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
auditLogs.GET("", auditHandler.List)
auditLogs.GET("/export", auditHandler.Export)
// 实时事件 SSE 流Dashboard 自刷新、桌面告警)
if deps.EventBroadcaster != nil {
eventsHandler := NewEventsHandler(deps.EventBroadcaster)
events := api.Group("/events")
events.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
events.GET("/stream", eventsHandler.Stream)
}
// 全局搜索
if deps.SearchService != nil {
searchHandler := NewSearchHandler(deps.SearchService)
searchGroup := api.Group("/search")
searchGroup.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
searchGroup.GET("", searchHandler.Search)
}
if deps.DatabaseDiscoveryService != nil {
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
database := api.Group("/database")
database.Use(AuthMiddleware(deps.JWTManager))
database.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
database.POST("/discover", databaseHandler.Discover)
}
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))
nodes.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
nodes.GET("", nodeHandler.List)
nodes.GET("/:id", nodeHandler.Get)
nodes.POST("", nodeHandler.Create)
nodes.PUT("/:id", nodeHandler.Update)
nodes.DELETE("/:id", nodeHandler.Delete)
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", nodeHandler.PreviewScript)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT
if deps.AgentService != nil {
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService)
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService, deps.RestoreService)
agent := api.Group("/agent")
agent.POST("/heartbeat", agentHandler.Heartbeat)
agent.POST("/commands/poll", agentHandler.Poll)
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
agent.POST("/records/:id", agentHandler.UpdateRecord)
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
agent.POST("/restores/:id", agentHandler.UpdateRestore)
// Agent v1安装脚本探活用仅 Self 端点
v1Agent := api.Group("/v1/agent")
@@ -180,6 +302,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
}
}
// 健康检查端点(公开、无认证、低开销)
// K8s/Swarm/Nomad 等编排系统使用这些端点做 liveness/readiness 探测。
healthHandler := NewHealthHandler(deps.DB, deps.Version)
engine.GET("/health", healthHandler.Live)
engine.GET("/ready", healthHandler.Ready)
// 在 /api 下也暴露一份,方便反向代理按 path 前缀统一路由
engine.GET("/api/health", healthHandler.Live)
engine.GET("/api/ready", healthHandler.Ready)
// 公开安装路由(不走 JWT 中间件)
if deps.InstallTokenService != nil {
gcCtx := deps.Context

View File

@@ -0,0 +1,28 @@
package http
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// SearchHandler 全局搜索。
type SearchHandler struct {
service *service.SearchService
}
func NewSearchHandler(s *service.SearchService) *SearchHandler {
return &SearchHandler{service: s}
}
// Search GET /search?q=关键字
func (h *SearchHandler) Search(c *gin.Context) {
query := c.Query("q")
result, err := h.service.Search(c.Request.Context(), query)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}

View File

@@ -0,0 +1,101 @@
package http
import (
"encoding/json"
"fmt"
"io"
stdhttp "net/http"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// TaskExportHandler 提供任务配置 JSON 导入/导出。
type TaskExportHandler struct {
service *service.TaskExportService
auditService *service.AuditService
}
func NewTaskExportHandler(s *service.TaskExportService, audit *service.AuditService) *TaskExportHandler {
return &TaskExportHandler{service: s, auditService: audit}
}
// Export GET /api/backup/tasks/export?ids=1,2,3
// 无 ids 参数时导出全部任务。返回 application/json + Content-Disposition。
func (h *TaskExportHandler) Export(c *gin.Context) {
var taskIDs []uint
if v := strings.TrimSpace(c.Query("ids")); v != "" {
for _, part := range strings.Split(v, ",") {
if id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32); err == nil {
taskIDs = append(taskIDs, uint(id))
}
}
}
payload, err := h.service.Export(c.Request.Context(), taskIDs)
if err != nil {
response.Error(c, err)
return
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
response.Error(c, apperror.Internal("TASK_EXPORT_MARSHAL_FAILED", "无法序列化导出内容", err))
return
}
filename := fmt.Sprintf("backupx-tasks-%s.json", time.Now().UTC().Format("20060102-150405"))
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
_, _ = c.Writer.Write(data)
recordAudit(c, h.auditService, "backup_task", "export", "backup_task", "", "",
fmt.Sprintf("导出 %d 个任务的配置为 JSON", payload.TaskCount))
}
// Import POST /api/backup/tasks/import
// Body: ExportPayload JSON。返回每个任务的创建/跳过结果。
func (h *TaskExportHandler) Import(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "无法读取请求体", err))
return
}
if len(body) == 0 {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "请求体为空", nil))
return
}
if len(body) > 1024*1024 { // 1MB 上限
c.Writer.WriteHeader(stdhttp.StatusRequestEntityTooLarge)
response.Error(c, apperror.BadRequest("TASK_IMPORT_TOO_LARGE", "导入文件过大(上限 1MB", nil))
return
}
var payload service.ExportPayload
if err := json.Unmarshal(body, &payload); err != nil {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "JSON 格式不合法", err))
return
}
if len(payload.Tasks) == 0 {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "文件中未包含任何任务", nil))
return
}
results, err := h.service.Import(c.Request.Context(), payload)
if err != nil {
response.Error(c, err)
return
}
succ := 0
skipped := 0
for _, r := range results {
if r.Success && !r.Skipped {
succ++
} else if r.Skipped {
skipped++
}
}
recordAudit(c, h.auditService, "backup_task", "import", "backup_task", "", "",
fmt.Sprintf("从 JSON 导入任务:创建 %d / 跳过 %d / 失败 %d", succ, skipped, len(results)-succ-skipped))
response.Success(c, results)
}

View File

@@ -0,0 +1,125 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type TaskTemplateHandler struct {
service *service.TaskTemplateService
auditService *service.AuditService
}
func NewTaskTemplateHandler(templateService *service.TaskTemplateService, auditService *service.AuditService) *TaskTemplateHandler {
return &TaskTemplateHandler{service: templateService, auditService: auditService}
}
func (h *TaskTemplateHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *TaskTemplateHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *TaskTemplateHandler) Create(c *gin.Context) {
var input service.TaskTemplateUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
return
}
creator := ""
if v, ok := c.Get(contextUsernameKey); ok {
if s, ok := v.(string); ok {
creator = s
}
}
item, err := h.service.Create(c.Request.Context(), creator, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "task_template", "create", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
fmt.Sprintf("创建任务模板: %s (类型: %s)", item.Name, item.TaskType))
response.Success(c, item)
}
func (h *TaskTemplateHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.TaskTemplateUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "task_template", "update", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
fmt.Sprintf("更新任务模板: %s", item.Name))
response.Success(c, item)
}
func (h *TaskTemplateHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "task_template", "delete", "task_template", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除任务模板 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}
// Apply 一键批量创建任务。Body: {variables: [{name, sourcePath, ...}, ...]}
func (h *TaskTemplateHandler) Apply(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.TaskTemplateApplyInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "应用参数不合法", err))
return
}
results, err := h.service.Apply(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
successCount := 0
for _, r := range results {
if r.Success {
successCount++
}
}
recordAudit(c, h.auditService, "task_template", "apply", "task_template", fmt.Sprintf("%d", id), "",
fmt.Sprintf("应用模板批量创建任务(成功 %d/%d", successCount, len(results)))
response.Success(c, results)
}

View File

@@ -0,0 +1,80 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// UserHandler 管理账号(仅 admin 可访问)。
type UserHandler struct {
service *service.UserService
auditService *service.AuditService
}
func NewUserHandler(userService *service.UserService, auditService *service.AuditService) *UserHandler {
return &UserHandler{service: userService, auditService: auditService}
}
func (h *UserHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *UserHandler) Create(c *gin.Context) {
var input service.UserUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "create", "user", fmt.Sprintf("%d", item.ID), item.Username,
fmt.Sprintf("创建用户 %s (角色: %s)", item.Username, item.Role))
response.Success(c, item)
}
func (h *UserHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.UserUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "update", "user", fmt.Sprintf("%d", id), item.Username,
fmt.Sprintf("更新用户 %s (角色: %s, 停用: %v)", item.Username, item.Role, item.Disabled))
response.Success(c, item)
}
func (h *UserHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "delete", "user", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除用户 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}

View File

@@ -0,0 +1,207 @@
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// VerificationHandler 提供验证记录列表/详情/SSE以及手动触发入口。
type VerificationHandler struct {
service *service.VerificationService
auditService *service.AuditService
}
func NewVerificationHandler(verifyService *service.VerificationService, auditService *service.AuditService) *VerificationHandler {
return &VerificationHandler{service: verifyService, auditService: auditService}
}
// TriggerByTask 接收任务级手动触发。使用最新成功备份为源。
func (h *VerificationHandler) TriggerByTask(c *gin.Context) {
taskID, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Mode string `json:"mode"`
}
_ = c.ShouldBindJSON(&input)
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
if triggeredBy == "" {
triggeredBy = "manual"
}
detail, err := h.service.StartByTask(c.Request.Context(), taskID, input.Mode, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_task", fmt.Sprintf("%d", taskID), "",
fmt.Sprintf("手动触发验证(任务 ID: %d, 验证记录 ID: %d, 模式: %s", taskID, detail.ID, detail.Mode))
response.Success(c, detail)
}
// TriggerByRecord 基于指定备份记录触发验证(允许验证历史备份)。
func (h *VerificationHandler) TriggerByRecord(c *gin.Context) {
recordID, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Mode string `json:"mode"`
}
_ = c.ShouldBindJSON(&input)
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
if triggeredBy == "" {
triggeredBy = "manual"
}
detail, err := h.service.Start(c.Request.Context(), recordID, input.Mode, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
fmt.Sprintf("手动触发验证(备份记录 ID: %d, 验证记录 ID: %d, 模式: %s", recordID, detail.ID, detail.Mode))
response.Success(c, detail)
}
func (h *VerificationHandler) List(c *gin.Context) {
filter, err := buildVerifyFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *VerificationHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *VerificationHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("VERIFY_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func buildVerifyFilter(c *gin.Context) (service.VerificationRecordListInput, error) {
var filter service.VerificationRecordListInput
if value := strings.TrimSpace(c.Query("taskId")); value != "" {
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "taskId 不合法", err)
}
v := uint(parsed)
filter.TaskID = &v
}
if value := strings.TrimSpace(c.Query("backupRecordId")); value != "" {
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
}
v := uint(parsed)
filter.BackupRecordID = &v
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeVerifySSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}