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
291 lines
7.6 KiB
Go
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})
|
|
}
|