package handler import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "sql-platform/internal/middleware" "sql-platform/internal/model" "github.com/gin-gonic/gin" ) // GET /api/v1/tickets/statistics func TicketStatistics(c *gin.Context) { userID, _, role := middleware.GetCurrentUser(c) type statusCount struct { Status string `json:"status"` Count int64 `json:"count"` } var counts []statusCount query := model.DB.Model(&model.Ticket{}).Select("status, count(*) as count") // Filter by user role switch role { case "developer": query = query.Where("applicant_id = ?", userID) case "dba", "auditor": // DBA sees tickets they submitted + tickets they need to approve query = query.Where( "applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)", userID, userID, ) // admin sees everything } query.Group("status").Scan(&counts) stats := model.TicketStatistics{} for _, c := range counts { switch c.Status { case "pending": stats.Pending = int(c.Count) case "reviewing": stats.Reviewing = int(c.Count) case "executing": stats.Executing = int(c.Count) case "success": stats.Completed = int(c.Count) case "failed": stats.Failed = int(c.Count) case "rejected": stats.Rejected = int(c.Count) } } stats.Total = stats.Pending + stats.Reviewing + stats.Executing + stats.Completed + stats.Failed + stats.Rejected c.JSON(http.StatusOK, stats) } // GET /api/v1/tickets func ListTickets(c *gin.Context) { userID, _, role := middleware.GetCurrentUser(c) status := c.Query("status") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if limit < 1 || limit > 100 { limit = 20 } query := model.DB.Model(&model.Ticket{}).Order("id DESC") // keyword search keyword := c.Query("keyword") if keyword != "" { query = query.Where("title LIKE ? OR ticket_no LIKE ?", "%"+keyword+"%", "%"+keyword+"%") } // Filter by user role switch role { case "developer": query = query.Where("applicant_id = ?", userID) case "dba", "auditor": query = query.Where( "applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)", userID, userID, ) } if status != "" && status != "all" { query = query.Where("status = ?", status) } var total int64 query.Count(&total) var tickets []model.Ticket query.Offset((page - 1) * limit).Limit(limit).Find(&tickets) // Enrich tickets with has_audit_step info type TicketWithMeta struct { model.Ticket HasAuditStep bool `json:"has_audit_step"` HasExecuteStep bool `json:"has_execute_step"` IsExecutor bool `json:"is_executor"` IsApprover bool `json:"is_approver"` } var result []TicketWithMeta for _, t := range tickets { var auditCount, execCount int64 model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'audit'", t.ID).Count(&auditCount) model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'execute'", t.ID).Count(&execCount) var isExec, isAppr int64 model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'execute' AND approver_id = ?", t.ID, userID).Count(&isExec) model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type != 'execute' AND step_type != 'submit' AND approver_id = ?", t.ID, userID).Count(&isAppr) result = append(result, TicketWithMeta{ Ticket: t, HasAuditStep: auditCount > 0, HasExecuteStep: execCount > 0, IsExecutor: isExec > 0, IsApprover: isAppr > 0, }) } c.JSON(http.StatusOK, gin.H{ "items": result, "total": total, "page": page, "page_size": limit, }) } // GET /api/v1/tickets/:id func GetTicket(c *gin.Context) { id := c.Param("id") var ticket model.Ticket if err := model.DB.First(&ticket, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) return } // Get approvals — enrich with user display names var approvals []model.TicketApproval model.DB.Where("ticket_id = ?", id).Order("step_number, id").Find(&approvals) type ApprovalWithNames struct { model.TicketApproval ApproverName string `json:"approver_name"` ActualApproverName string `json:"actual_approver_name"` } var approvalsResult []ApprovalWithNames for _, a := range approvals { item := ApprovalWithNames{TicketApproval: a} if a.ApproverID != nil { var u model.User if model.DB.First(&u, *a.ApproverID).Error == nil { item.ApproverName = u.DisplayName } } if a.ActualApproverID != nil { var u model.User if model.DB.First(&u, *a.ActualApproverID).Error == nil { item.ActualApproverName = u.DisplayName } } approvalsResult = append(approvalsResult, item) } // Get SQL items var sqlItems []model.TicketSQLItem model.DB.Where("ticket_id = ?", id).Order("seq").Find(&sqlItems) // Flatten ticket fields into response + add nested data result := gin.H{} // Marshal ticket to JSON then unmarshal to map for flattening ticketBytes, _ := json.Marshal(ticket) json.Unmarshal(ticketBytes, &result) result["approvals"] = approvalsResult result["sql_items"] = sqlItems // Add applicant_name and instance_name for frontend var applicant model.User if err := model.DB.First(&applicant, ticket.ApplicantID).Error; err == nil { result["applicant_name"] = applicant.DisplayName } var inst model.Instance if err := model.DB.First(&inst, ticket.InstanceID).Error; err == nil { result["instance_name"] = inst.Name } c.JSON(http.StatusOK, result) } // POST /api/v1/tickets func CreateTicket(c *gin.Context) { userID, _, _ := middleware.GetCurrentUser(c) var input struct { Title string `json:"title" binding:"required"` DBType string `json:"db_type" binding:"required"` InstanceID int `json:"instance_id" binding:"required"` TargetDatabase string `json:"target_database" binding:"required"` SQLContent string `json:"sql_content" binding:"required"` RollbackSQL *string `json:"rollback_sql"` Priority *string `json:"priority"` Reason *string `json:"reason"` WorkflowID *int `json:"workflow_id"` RiskLevel *string `json:"risk_level"` ExecStrategy *string `json:"exec_strategy"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } // Auto-fill workflow_id from instance if not provided if input.WorkflowID == nil || *input.WorkflowID == 0 { var inst model.Instance if model.DB.First(&inst, input.InstanceID).Error == nil && inst.WorkflowID != nil { input.WorkflowID = inst.WorkflowID } } status := "pending" priority := "medium" riskLevel := "low" execStrategy := "stop_on_error" if input.Priority != nil { priority = *input.Priority } if input.RiskLevel != nil { riskLevel = *input.RiskLevel } if input.ExecStrategy != nil { execStrategy = *input.ExecStrategy } // Generate ticket number now := time.Now() ticketNo := now.Format("20060102") + "-" + fmt.Sprintf("%04d", time.Now().UnixNano()%10000) ticket := model.Ticket{ Title: input.Title, DBType: input.DBType, InstanceID: input.InstanceID, TargetDatabase: input.TargetDatabase, SQLContent: input.SQLContent, RollbackSQL: input.RollbackSQL, Status: &status, Priority: &priority, ApplicantID: userID, Reason: input.Reason, WorkflowID: input.WorkflowID, RiskLevel: &riskLevel, ExecStrategy: &execStrategy, TicketNo: &ticketNo, } if err := model.DB.Create(&ticket).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } // Create approval records based on workflow or default to admin hasAuditStep := false if input.WorkflowID != nil && *input.WorkflowID > 0 { // Load workflow steps var wfSteps []model.WorkflowStep model.DB.Where("workflow_id = ?", *input.WorkflowID).Order("step_number").Find(&wfSteps) for _, ws := range wfSteps { if ws.StepType == "submit" { continue // submit step needs no approver } if ws.StepType != "execute" { hasAuditStep = true } // Parse approver_ids from JSON var approverIDs []int if ws.ApproverIDs != nil && *ws.ApproverIDs != "" { json.Unmarshal([]byte(*ws.ApproverIDs), &approverIDs) } // Create one approval record per approver (any one can approve) if len(approverIDs) > 0 { for _, uid := range approverIDs { uidCopy := uid stepName := ws.Name stepType := ws.StepType approval := model.TicketApproval{ TicketID: ticket.ID, StepNumber: ws.StepNumber, StepName: &stepName, StepType: &stepType, ApproverID: &uidCopy, } model.DB.Create(&approval) } } else { // No specific users — use role-based fallback stepName := ws.Name stepType := ws.StepType var roleUser model.User role := "admin" if ws.ApproverRole != nil && *ws.ApproverRole != "" { role = *ws.ApproverRole } if model.DB.Where("role = ? AND is_active = 1", role).First(&roleUser).Error == nil { rid := roleUser.ID approval := model.TicketApproval{ TicketID: ticket.ID, StepNumber: ws.StepNumber, StepName: &stepName, StepType: &stepType, ApproverID: &rid, ApproverRole: &role, } model.DB.Create(&approval) } } } } else { // No workflow — default: first admin as approver stepName := "审核" stepType := "audit" approverRole := "admin" var adminUser model.User if err := model.DB.Where("role = ? AND is_active = 1", "admin").First(&adminUser).Error; err == nil { adminID := adminUser.ID approval := model.TicketApproval{ TicketID: ticket.ID, StepNumber: 1, StepName: &stepName, StepType: &stepType, ApproverID: &adminID, ApproverRole: &approverRole, } model.DB.Create(&approval) } } // If workflow has no audit step (only submit + execute), skip to reviewing if !hasAuditStep { reviewing := "reviewing" model.DB.Model(&ticket).Update("status", reviewing) ticket.Status = &reviewing } c.JSON(http.StatusCreated, ticket) } // POST /api/v1/tickets/:id/approve func ApproveTicket(c *gin.Context) { id := c.Param("id") userID, userRole, _ := middleware.GetCurrentUser(c) // Role check: only admin/dba/auditor can approve if userRole != "admin" && userRole != "dba" && userRole != "auditor" { c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审核操作需要管理员、DBA 或审核员角色"}) return } var ticket model.Ticket if err := model.DB.First(&ticket, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) return } var input struct { Action string `json:"action" binding:"required"` // approve, reject Comment *string `json:"comment"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } now := time.Now() // Update approval record — auto-create if missing (legacy tickets) var approval model.TicketApproval if err := model.DB.Where("ticket_id = ? AND approver_id = ? AND action IS NULL", id, userID).First(&approval).Error; err != nil { // No approval record — authorized role can create one stepName := "审核" stepType := "audit" approverRole := userRole approval = model.TicketApproval{ TicketID: ticket.ID, StepNumber: 1, StepName: &stepName, StepType: &stepType, ApproverID: &userID, ApproverRole: &approverRole, } model.DB.Create(&approval) } approval.Action = &input.Action approval.Comment = input.Comment approval.ActualApproverID = &userID approval.CompletedAt = &now model.DB.Save(&approval) // Update ticket status newStatus := "approved" if input.Action == "reject" { newStatus = "rejected" } else if input.Action == "approve" { // Check if there are more pending approvals var pendingCount int64 model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND action IS NULL", id).Count(&pendingCount) if pendingCount > 0 { newStatus = "reviewing" } } ticket.Status = &newStatus ticket.ReviewTime = &now model.DB.Save(&ticket) c.JSON(http.StatusOK, gin.H{"ticket": ticket, "approval": approval}) } // POST /api/v1/tickets/:id/execute func ExecuteTicket(c *gin.Context) { id := c.Param("id") // Role check: only admin/dba can execute _, userRole, _ := middleware.GetCurrentUser(c) if userRole != "admin" && userRole != "dba" { c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:执行操作需要管理员或 DBA 角色"}) return } var ticket model.Ticket if err := model.DB.First(&ticket, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) return } // Check ticket status — allow approved OR reviewing (will auto-approve execute step) if ticket.Status != nil && *ticket.Status != "approved" && *ticket.Status != "reviewing" { c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"}) return } // Auto-approve any pending execute-type approval for the current user userID, _, _ := middleware.GetCurrentUser(c) // Verify current user is an executor for this ticket var executorCheck int64 model.DB.Model(&model.TicketApproval{}). Where("ticket_id = ? AND step_type = 'execute' AND (approver_id = ? OR approver_id IS NULL)", id, userID). Count(&executorCheck) if executorCheck == 0 { c.JSON(http.StatusForbidden, gin.H{"detail": "你不是此工单的执行人"}) return } var pendingExec model.TicketApproval if err := model.DB.Where("ticket_id = ? AND step_type = 'execute' AND action IS NULL", id).First(&pendingExec).Error; err == nil { // Auto-approve the execute step now := time.Now() action := "approve" comment := "自动通过(执行触发)" pendingExec.Action = &action pendingExec.Comment = &comment pendingExec.ActualApproverID = &userID pendingExec.CompletedAt = &now model.DB.Save(&pendingExec) } // Check if all audit steps are approved var pendingAudit int64 model.DB.Model(&model.TicketApproval{}). Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id). Count(&pendingAudit) if pendingAudit > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": "还有未完成的审核步骤"}) return } // Check for rejected audit steps var rejectedAudit int64 model.DB.Model(&model.TicketApproval{}). Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id). Count(&rejectedAudit) if rejectedAudit > 0 { c.JSON(http.StatusBadRequest, gin.H{"detail": "审核已被拒绝,无法执行"}) return } // Get instance info var instance model.Instance if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "Instance not found"}) return } status := "executing" ticket.Status = &status now := time.Now() ticket.ExecuteTime = &now model.DB.Save(&ticket) // Execute SQL based on db_type var execResult string var execErr string if ticket.DBType == "redis" { // Execute Redis command (write operations allowed in ticket workflow) redisDB := 0 if ticket.TargetDatabase != "" { if n, err := strconv.Atoi(strings.TrimPrefix(ticket.TargetDatabase, "db")); err == nil && n >= 0 { redisDB = n } } client, err := getRedisClient(&instance, redisDB) if err != nil { execErr = fmt.Sprintf("Redis connection failed: %v", err) } else { defer client.Close() ctx := context.Background() parts := parseCommandParts(ticket.SQLContent) if len(parts) == 0 { execErr = "empty command" } else { cmd := strings.ToUpper(parts[0]) args := make([]interface{}, len(parts)) for i, p := range parts { args[i] = p } result, err := client.Do(ctx, args...).Result() if err != nil { execErr = fmt.Sprintf("%s failed: %v", cmd, err) } else { execResult = fmt.Sprintf("[OK] %s → %v", ticket.SQLContent, result) } } } } else { // MySQL/PG execution db, err := getDBConnection(&instance, ticket.TargetDatabase) if err != nil { execErr = fmt.Sprintf("DB connection failed: %v", err) } else { defer db.Close() // Split multi-statement by semicolons statements := splitSQL(ticket.SQLContent) var results []string for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } _, err := db.Exec(stmt) if err != nil { results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) // stop_on_error execErr = err.Error() break } else { results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50))) } } if len(results) > 0 { execResult = strings.Join(results, "\n") } } } // Update final status if execErr != "" { failStatus := "failed" ticket.Status = &failStatus ticket.ExecuteResult = &execErr } else { doneStatus := "success" ticket.Status = &doneStatus ticket.ExecuteResult = &execResult } model.DB.Save(&ticket) c.JSON(http.StatusOK, gin.H{"message": "Execution completed", "ticket": ticket}) } func splitSQL(sql string) []string { return strings.Split(sql, ";") } func truncateStr(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // POST /api/v1/tickets/:id/cancel func CancelTicket(c *gin.Context) { id := c.Param("id") userID, _, _ := middleware.GetCurrentUser(c) var ticket model.Ticket if err := model.DB.First(&ticket, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) return } // Only applicant or admin can cancel if ticket.ApplicantID != userID { _, _, role := middleware.GetCurrentUser(c) if role != "admin" { c.JSON(http.StatusForbidden, gin.H{"detail": "Only applicant or admin can cancel"}) return } } if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing") { c.JSON(http.StatusBadRequest, gin.H{"detail": "Can only cancel pending or reviewing tickets"}) return } status := "cancelled" ticket.Status = &status now := time.Now() ticket.ReviewTime = &now model.DB.Save(&ticket) c.JSON(http.StatusOK, gin.H{"message": "Ticket cancelled", "ticket": ticket}) } // POST /api/v1/tickets/:id/rollback func RollbackTicket(c *gin.Context) { id := c.Param("id") var ticket model.Ticket if err := model.DB.First(&ticket, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) return } if ticket.RollbackSQL == nil || *ticket.RollbackSQL == "" { c.JSON(http.StatusBadRequest, gin.H{"detail": "No rollback SQL available"}) return } // Get instance var instance model.Instance if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "Instance not found"}) return } if ticket.DBType == "redis" { c.JSON(http.StatusBadRequest, gin.H{"detail": "Redis rollback not supported"}) return } db, err := getDBConnection(&instance, ticket.TargetDatabase) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) return } defer db.Close() // Execute rollback SQL statements := splitSQL(*ticket.RollbackSQL) var results []string var execErr string for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } _, err := db.Exec(stmt) if err != nil { results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) execErr = err.Error() break } else { results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50))) } } if execErr != "" { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Rollback failed", "error": execErr, "results": results}) return } status := "rollback" ticket.Status = &status execResult := strings.Join(results, "\n") ticket.ExecuteResult = &execResult model.DB.Save(&ticket) c.JSON(http.StatusOK, gin.H{"message": "Rollback completed", "results": results}) } // GET /api/v1/tickets/:id/timeline func GetTicketTimeline(c *gin.Context) { id := c.Param("id") var ticket model.Ticket if err := model.DB.First(&ticket, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) return } type TimelineEntry struct { ID int `json:"id"` Action string `json:"action"` User string `json:"user"` Comment *string `json:"comment"` Timestamp string `json:"timestamp"` Status string `json:"status"` } var timeline []TimelineEntry // 1. Created event timeline = append(timeline, TimelineEntry{ ID: 1, Action: "created", User: getApplicantName(ticket.ApplicantID), Timestamp: ticket.CreateTime.Format(time.RFC3339), Status: "pending", }) // 2. Approval events from ticket_approvals var approvals []model.TicketApproval model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals) for _, a := range approvals { action := "pending_review" userName := getUserName(a.ActualApproverID) if a.Action != nil { action = *a.Action } ts := a.CreatedAt.Format(time.RFC3339) if a.CompletedAt != nil { ts = a.CompletedAt.Format(time.RFC3339) } timeline = append(timeline, TimelineEntry{ ID: a.ID, Action: action, User: userName, Comment: a.Comment, Timestamp: ts, }) } // 3. Executed event if ticket.ExecuteTime != nil { execAction := "executed" if ticket.Status != nil && *ticket.Status == "failed" { execAction = "execution_failed" } else if ticket.Status != nil && *ticket.Status == "completed" { execAction = "executed" } timeline = append(timeline, TimelineEntry{ ID: 99, Action: execAction, Timestamp: ticket.ExecuteTime.Format(time.RFC3339), Status: func() string { if ticket.Status != nil { return *ticket.Status }; return "" }(), }) } c.JSON(http.StatusOK, gin.H{"timeline": timeline}) } // GET /api/v1/tickets/:id/approvals func GetTicketApprovals(c *gin.Context) { id := c.Param("id") var approvals []model.TicketApproval if err := model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } // Enrich with approver names type ApprovalResponse struct { model.TicketApproval ApproverName string `json:"approver_name"` } var result []ApprovalResponse for _, a := range approvals { ar := ApprovalResponse{TicketApproval: a} ar.ApproverName = getUserName(a.ApproverID) result = append(result, ar) } c.JSON(http.StatusOK, result) } // Helper: get user name by ID (pointer-safe) func getUserName(userID *int) string { if userID == nil { return "" } var user model.User if model.DB.First(&user, *userID).Error == nil { return user.DisplayName } return "" } // Helper: get applicant name by ID func getApplicantName(userID int) string { var user model.User if model.DB.First(&user, userID).Error == nil { return user.DisplayName } return "" }