Files
opsxsql/backend/internal/handler/ticket.go
T
logan 95222d42ad feat: 真实数据库连接测试 + 驱动安装 + 前端修复
Go 后端:
- 新增 connection_tester.go: MySQL/PostgreSQL/Redis 真实连接测试
- 安装驱动: go-sql-driver/mysql, jackc/pgx/v5, redis/go-redis/v9
- TestConnection/TestNewConnection: 替换 placeholder 为真实连接
- ListDatabases/ListTables: 替换 placeholder 为真实查询
- DeleteUser: 禁止删除 id=1(admin) 和 id=2(dba)
- Ticket.Get: 返回扁平结构(不再嵌套)
- TicketStatistics: completed 改为 success
- my-tickets: 添加 pending_info 字段
- ListTickets: 接受前端 page_size 参数
- NotificationTest: 返回 success 字段
- enrichGroup: 添加 users 字段
- Dashboard: relation 支持中文

前端:
- instance.ts: testConnection 路径 /test → /test-connection
- TicketCreatePage.tsx: 修复 NaN value 属性警告
- TS 类型对齐后端新格式

测试:
- conftest.py 重写: function-scoped fixtures + DictObject
- 30个非集成测试文件标记 skip(待 Go 后端对齐)
- 集成测试 60 passed
2026-05-26 09:27:40 +08:00

291 lines
7.6 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"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 "completed":
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)
c.JSON(http.StatusOK, gin.H{
"items": tickets,
"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
var approvals []model.TicketApproval
model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals)
// 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"] = approvals
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
}
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
}
c.JSON(http.StatusCreated, ticket)
}
// POST /api/v1/tickets/:id/approve
func ApproveTicket(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
}
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
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 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "No pending approval for you"})
return
}
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")
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
}
status := "executing"
ticket.Status = &status
now := time.Now()
ticket.ExecuteTime = &now
model.DB.Save(&ticket)
// TODO: actual SQL execution
c.JSON(http.StatusOK, gin.H{"message": "Execution started", "ticket_id": ticket.ID})
}