diff --git a/server/internal/http/node_handler.go b/server/internal/http/node_handler.go index f915a6c..60e24f8 100644 --- a/server/internal/http/node_handler.go +++ b/server/internal/http/node_handler.go @@ -5,18 +5,32 @@ import ( stdhttp "net/http" "strconv" + "backupx/server/internal/apperror" + "backupx/server/internal/installscript" "backupx/server/internal/service" "backupx/server/pkg/response" "github.com/gin-gonic/gin" ) type NodeHandler struct { - service *service.NodeService - auditService *service.AuditService + service *service.NodeService + auditService *service.AuditService + installTokenSvc *service.InstallTokenService + externalURL string } -func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler { - return &NodeHandler{service: service, auditService: auditService} +func NewNodeHandler( + nodeService *service.NodeService, + auditService *service.AuditService, + installTokenSvc *service.InstallTokenService, + externalURL string, +) *NodeHandler { + return &NodeHandler{ + service: nodeService, + auditService: auditService, + installTokenSvc: installTokenSvc, + externalURL: externalURL, + } } func (h *NodeHandler) List(c *gin.Context) { @@ -128,3 +142,135 @@ func (h *NodeHandler) Heartbeat(c *gin.Context) { } response.Success(c, gin.H{"status": "ok"}) } + +// BatchCreate 批量创建远程节点。 +func (h *NodeHandler) BatchCreate(c *gin.Context) { + var input struct { + Names []string `json:"names" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()}) + return + } + results, err := h.service.BatchCreate(c.Request.Context(), input.Names) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "node", "batch_create", "node", "", + fmt.Sprintf("%d", len(results)), fmt.Sprintf("批量创建 %d 个节点", len(results))) + response.Success(c, results) +} + +// RotateToken 轮换节点的 agent token。 +func (h *NodeHandler) RotateToken(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.Error(c, err) + return + } + tok, err := h.service.RotateToken(c.Request.Context(), uint(id)) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "node", "rotate_token", "node", + fmt.Sprintf("%d", id), "", + fmt.Sprintf("轮换节点 Token (ID: %d)", id)) + response.Success(c, gin.H{"newToken": tok}) +} + +// CreateInstallToken 生成一次性安装令牌。 +func (h *NodeHandler) CreateInstallToken(c *gin.Context) { + if h.installTokenSvc == nil { + response.Error(c, apperror.New(stdhttp.StatusServiceUnavailable, + "INSTALL_TOKEN_DISABLED", "一键部署未启用", nil)) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.Error(c, err) + return + } + var input struct { + Mode string `json:"mode"` + Arch string `json:"arch"` + AgentVersion string `json:"agentVersion"` + DownloadSrc string `json:"downloadSrc"` + TTLSeconds int `json:"ttlSeconds"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()}) + return + } + // 默认值 + if input.Mode == "" { + input.Mode = "systemd" + } + if input.Arch == "" { + input.Arch = "auto" + } + if input.DownloadSrc == "" { + input.DownloadSrc = "github" + } + if input.TTLSeconds == 0 { + input.TTLSeconds = 900 + } + + out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{ + NodeID: uint(id), + Mode: input.Mode, + Arch: input.Arch, + AgentVersion: input.AgentVersion, + DownloadSrc: input.DownloadSrc, + TTLSeconds: input.TTLSeconds, + CreatedByID: 0, // 如需关联 userID,后续可通过 auth 中间件注入 + }) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "install_token", "create", "node", + fmt.Sprintf("%d", id), out.Node.Name, + fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds)) + + masterURL := resolveMasterURL(c, h.externalURL) + body := gin.H{ + "installToken": out.Token, + "expiresAt": out.ExpiresAt, + "url": masterURL + "/install/" + out.Token, + "composeUrl": "", + } + if input.Mode == "docker" { + body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml" + } + response.Success(c, body) +} + +// PreviewScript 预览安装脚本(token 字段用 占位,不消费 install token)。 +// 用于 UI Step 3 展开"脚本预览"。 +func (h *NodeHandler) PreviewScript(c *gin.Context) { + mode := c.DefaultQuery("mode", "systemd") + arch := c.DefaultQuery("arch", "auto") + ver := c.Query("agentVersion") + if ver == "" { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "agentVersion required"}) + return + } + src := c.DefaultQuery("downloadSrc", "github") + ctx := installscript.Context{ + MasterURL: resolveMasterURL(c, h.externalURL), + AgentToken: "", + AgentVersion: ver, + Mode: mode, + Arch: arch, + DownloadBase: installscript.DownloadBaseFor(src), + InstallPrefix: "/opt/backupx-agent", + } + script, err := installscript.RenderScript(ctx) + if err != nil { + response.Error(c, err) + return + } + c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script)) +} diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 47445ed..67c9026 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -141,7 +141,8 @@ func NewRouter(deps RouterDependencies) *gin.Engine { database.POST("/discover", databaseHandler.Discover) } - nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService) + // 临时让 build 通过:InstallTokenService 与 externalURL 传 nil 和 "",Task 11 会替换成真实值 + nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, nil, "") nodes := api.Group("/nodes") nodes.Use(AuthMiddleware(deps.JWTManager)) nodes.GET("", nodeHandler.List)