功能: 一键部署 Agent 向导 (#44)

This commit is contained in:
Wu Qing
2026-04-19 17:25:34 +08:00
committed by GitHub
parent 66373fa8e4
commit 83bf5ec656
32 changed files with 3177 additions and 207 deletions

View File

@@ -5,18 +5,59 @@ import (
stdhttp "net/http"
"strconv"
"backupx/server/internal/apperror"
"backupx/server/internal/installscript"
"backupx/server/internal/repository"
"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
userRepo repository.UserRepository
externalURL string
}
func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler {
return &NodeHandler{service: service, auditService: auditService}
// NewNodeHandler 构造 handler。
// userRepo 用于把 JWT subject用户名解析为 user.ID填入 install_token.created_by_id 做审计追溯;
// 传 nil 时 created_by_id 记为 0仍可用不阻断
func NewNodeHandler(
nodeService *service.NodeService,
auditService *service.AuditService,
installTokenSvc *service.InstallTokenService,
userRepo repository.UserRepository,
externalURL string,
) *NodeHandler {
return &NodeHandler{
service: nodeService,
auditService: auditService,
installTokenSvc: installTokenSvc,
userRepo: userRepo,
externalURL: externalURL,
}
}
// resolveCurrentUserID 从 JWT subject 解析出 user.ID失败返回 0。
func (h *NodeHandler) resolveCurrentUserID(c *gin.Context) uint {
if h.userRepo == nil {
return 0
}
subjectValue, ok := c.Get(contextUserSubjectKey)
if !ok {
return 0
}
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil || subject == "" {
return 0
}
user, err := h.userRepo.FindByUsername(c.Request.Context(), subject)
if err != nil || user == nil {
return 0
}
return user.ID
}
func (h *NodeHandler) List(c *gin.Context) {
@@ -128,3 +169,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: h.resolveCurrentUserID(c),
})
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 字段用 <AGENT_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: "<AGENT_TOKEN>",
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))
}