package handler import ( "crypto/subtle" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "github.com/1024XEngineer/xinfra/server/internal/model" "github.com/1024XEngineer/xinfra/server/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type DeliveryHandler struct{ service *service.DeliveryService } func NewDeliveryHandler(s *service.DeliveryService) *DeliveryHandler { return &DeliveryHandler{service: s} } type DeliveryCallbackHandler struct { service *service.DeliveryService token string } func NewDeliveryCallbackHandler(s *service.DeliveryService, token string) *DeliveryCallbackHandler { return &DeliveryCallbackHandler{service: s, token: strings.TrimSpace(token)} } func (h *DeliveryCallbackHandler) authorize(c *gin.Context) bool { provided := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) if h.token == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(h.token)) != 1 { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid service credential"}) return false } return true } func (h *DeliveryCallbackHandler) StageEvent(c *gin.Context) { if !h.authorize(c) { return } var req service.DeliveryStageEventInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.service.HandleStageEvent(c.Request.Context(), c.Param("id"), req); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusAccepted, gin.H{"ok": true}) } func (h *DeliveryCallbackHandler) AWXJobEvent(c *gin.Context) { if !h.authorize(c) { return } var req service.AWXJobNotificationInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid AWX webhook JSON: " + err.Error()}) return } if err := h.service.HandleAWXJobNotification(c.Request.Context(), req); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusAccepted, gin.H{"ok": true}) } // CreateMySQL 提交 MySQL 交付请求 // @Summary 提交 MySQL 一键交付 // @Description 创建一个 MySQL 交付任务,调度器会自动分配主机、调用 AWX 执行部署 // @Tags delivery // @Accept json // @Produce json // @Param Idempotency-Key header string true "幂等键(防重复提交,最长 128 字符)" // @Param body body service.MySQLDeliveryInput true "交付参数" // @Success 202 {object} map[string]any "任务已创建" // @Success 200 {object} map[string]any "幂等重放(相同 Idempotency-Key 已存在)" // @Failure 400 {object} map[string]any "参数错误" // @Failure 401 {object} map[string]any "未授权" // @Router /auth/api/v1/delivery/mysql [post] // @Security BearerAuth func (h *DeliveryHandler) CreateMySQL(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } var req service.MySQLDeliveryInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } task, existing, err := h.service.CreateTask(c.Request.Context(), claims.UserID, claims.IsAdmin, c.GetHeader("Idempotency-Key"), req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } status := http.StatusAccepted if existing { status = http.StatusOK } c.JSON(status, gin.H{"task": task, "idempotent_replay": existing}) } // List 获取交付任务列表 // @Summary 获取交付任务列表 // @Description 返回当前用户可见的交付任务列表(管理员可见全部) // @Tags delivery // @Produce json // @Param business_line_id query uint64 false "业务线 ID 过滤" // @Success 200 {object} map[string]any "items: 任务数组" // @Failure 401 {object} map[string]any "未授权" // @Router /auth/api/v1/delivery/tasks [get] // @Security BearerAuth func (h *DeliveryHandler) List(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } var businessLineID uint64 if raw := c.Query("business_line_id"); raw != "" { businessLineID, _ = strconv.ParseUint(raw, 10, 64) } items, err := h.service.ListTasks(c.Request.Context(), claims.UserID, claims.IsAdmin, service.DeliveryTaskListFilter{ BusinessLineID: businessLineID, Component: c.Query("component"), ActiveOnly: strings.EqualFold(c.Query("active"), "true"), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"items": items}) } // Get 获取交付任务详情 // @Summary 获取交付任务详情 // @Description 返回指定任务的详细信息及事件流 // @Tags delivery // @Produce json // @Param id path string true "任务 ID" // @Success 200 {object} map[string]any "task + events" // @Failure 401 {object} map[string]any "未授权" // @Failure 404 {object} map[string]any "任务不存在" // @Router /auth/api/v1/delivery/tasks/{id} [get] // @Security BearerAuth func (h *DeliveryHandler) Get(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } task, events, err := h.service.GetTask(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin) if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"task": task, "events": events}) } func (h *DeliveryHandler) Stream(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } taskID := c.Param("id") task, events, err := h.service.GetTask(c.Request.Context(), taskID, claims.UserID, claims.IsAdmin) if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } updates, cancel := h.service.SubscribeTask(taskID) defer cancel() c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") c.Status(http.StatusOK) writeSSE(c.Writer, "snapshot", service.DeliveryTaskSnapshot{Task: task, Events: events}) c.Writer.Flush() for { select { case <-c.Request.Context().Done(): return case snapshot, ok := <-updates: if !ok { return } writeSSE(c.Writer, "snapshot", snapshot) c.Writer.Flush() } } } func writeSSE(w http.ResponseWriter, event string, payload any) { raw, err := json.Marshal(payload) if err != nil { return } _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, raw) } // Cancel 取消交付任务 // @Summary 取消交付任务 // @Description 取消一个正在执行或等待中的交付任务 // @Tags delivery // @Produce json // @Param id path string true "任务 ID" // @Success 200 {object} map[string]any "ok: true" // @Failure 401 {object} map[string]any "未授权" // @Failure 409 {object} map[string]any "无法取消(状态冲突)" // @Router /auth/api/v1/delivery/tasks/{id}/cancel [post] // @Security BearerAuth func (h *DeliveryHandler) Cancel(c *gin.Context) { claims, ok := CurrentClaims(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } if err := h.service.Cancel(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // Targets 获取可用部署目标 // @Summary 获取可用部署目标 // @Description 从 AWX 动态返回可用 Job Template 及其 Inventory hosts // @Tags delivery // @Produce json // @Param component query string false "组件过滤,例如 mysql" // @Success 200 {object} map[string]any "items: 部署目标数组" // @Failure 500 {object} map[string]any "内部错误" // @Router /auth/api/v1/delivery/targets [get] // @Security BearerAuth func (h *DeliveryHandler) Targets(c *gin.Context) { items, err := h.service.ListTargets(c.Request.Context(), c.Query("component")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"items": items}) } type quotaPayload struct { BusinessLineID uint64 `json:"business_line_id" binding:"required"` TargetID uint64 `json:"target_id" binding:"required"` CPUMilli int64 `json:"cpu_milli" binding:"required"` MemoryMi int64 `json:"memory_mi" binding:"required"` StorageGi int64 `json:"storage_gi" binding:"required"` InstanceLimit int64 `json:"instance_limit" binding:"required"` } // UpsertQuota 创建或更新资源配额 // @Summary 创建或更新资源配额 // @Description 管理员为指定业务线 + 部署目标设置资源配额 // @Tags delivery // @Accept json // @Produce json // @Param body body quotaPayload true "配额参数" // @Success 200 {object} model.ResourceQuota "配额已更新" // @Failure 400 {object} map[string]any "参数错误" // @Failure 401 {object} map[string]any "未授权" // @Router /auth/api/v1/delivery/quotas [put] // @Security BearerAuth func (h *DeliveryHandler) UpsertQuota(c *gin.Context) { if !requirePlatformAdmin(c) { return } var req quotaPayload if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var item model.ResourceQuota err := h.service.DB().Where("business_line_id = ? AND target_id = ?", req.BusinessLineID, req.TargetID).First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { item = model.ResourceQuota{BusinessLineID: req.BusinessLineID, TargetID: req.TargetID} err = nil } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } item.CPUMilli = req.CPUMilli item.MemoryMi = req.MemoryMi item.StorageGi = req.StorageGi item.InstanceLimit = req.InstanceLimit if err := h.service.DB().Save(&item).Error; err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, item) }