package handler import ( "crypto/subtle" "errors" "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 ExecutionHandler struct { service *service.DeliveryService token string } func NewExecutionHandler(s *service.DeliveryService, token string) *ExecutionHandler { return &ExecutionHandler{service: s, token: strings.TrimSpace(token)} } type executionPayload struct { TaskID string `json:"task_id" binding:"required"` PayloadHash string `json:"payload_hash" binding:"required"` IdempotencyKey string `json:"idempotency_key" binding:"required"` } func (h *ExecutionHandler) Create(c *gin.Context) { provided := 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 } var req executionPayload if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } job, replay, err := h.service.CreateExecution(c.Request.Context(), req.TaskID, req.PayloadHash, req.IdempotencyKey) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } status := http.StatusCreated if replay { status = http.StatusOK } c.JSON(status, gin.H{"execution": job, "idempotent_replay": replay}) } // 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, businessLineID) 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}) } // 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) }