mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-03 00:39:48 +08:00
功能: 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:
@@ -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))
|
||||
|
||||
93
server/internal/http/api_key_handler.go
Normal file
93
server/internal/http/api_key_handler.go
Normal 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 Key(admin 专属)。
|
||||
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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
package http
|
||||
|
||||
const contextUserSubjectKey = "userSubject"
|
||||
const (
|
||||
contextUserSubjectKey = "userSubject"
|
||||
contextUserRoleKey = "userRole"
|
||||
contextUsernameKey = "username"
|
||||
// contextAuthSubjectKey 标识认证主体来源(user | api_key),便于审计追踪。
|
||||
contextAuthSubjectKey = "authSubject"
|
||||
)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
81
server/internal/http/events_handler.go
Normal file
81
server/internal/http/events_handler.go
Normal 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
|
||||
}
|
||||
75
server/internal/http/health_handler.go
Normal file
75
server/internal/http/health_handler.go
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
128
server/internal/http/replication_handler.go
Normal file
128
server/internal/http/replication_handler.go
Normal 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
|
||||
}
|
||||
162
server/internal/http/restore_record_handler.go
Normal file
162
server/internal/http/restore_record_handler.go
Normal 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
|
||||
}
|
||||
@@ -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 API(token 认证,无需 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
|
||||
|
||||
28
server/internal/http/search_handler.go
Normal file
28
server/internal/http/search_handler.go
Normal 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)
|
||||
}
|
||||
101
server/internal/http/task_export_handler.go
Normal file
101
server/internal/http/task_export_handler.go
Normal 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)
|
||||
}
|
||||
125
server/internal/http/task_template_handler.go
Normal file
125
server/internal/http/task_template_handler.go
Normal 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)
|
||||
}
|
||||
80
server/internal/http/user_handler.go
Normal file
80
server/internal/http/user_handler.go
Normal 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})
|
||||
}
|
||||
207
server/internal/http/verification_handler.go
Normal file
207
server/internal/http/verification_handler.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user