feat: 细节打磨 - 全面修复核心功能
后端修复: - ticket: 添加独立 CancelTicket/RollbackTicket/GetTicketTimeline/GetTicketApprovals handler (之前 cancel/rollback 都错误调用 ApproveTicket, timeline/approvals 调用 GetTicket) - instance_detail: 7个 TODO placeholder 端点全部实现真实 MySQL 查询 (databases/tables/db-schema/table-detail/processlist/status/users) - instance_detail: ExecuteQuery 添加真实 Redis 命令执行(只读命令白名单) - redis: 全部 handler 从 placeholder 改为真实 go-redis 实现 (info/dbsize/keys/key-detail/slowlog/clients/command/create-key/delete-key) - router: 添加 sql-assistant/format, sql-assistant/analyze, tickets/audit 路由 - router: 添加 dictionary/*, mongo/* stub 路由(前端不报 404) - stub_handlers: 新增 SQL 审计/格式化/分析 handler + dictionary/mongo stub - model: TicketStatistics.Completed JSON tag 改为 success 匹配前端 - ticket: ExecuteTicket 完成 status 改为 success 而非 completed 前端兼容: - 所有前端 API 路径后端都有对应路由(无 404) - TicketStatistics 返回 success 字段与前端 stats?.success 对齐
This commit is contained in:
1 parent
9372053574
commit
f2e93a09c0
5 files changed
+1577
-67
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sql-platform/internal/middleware"
|
||||
@@ -49,7 +50,7 @@ func TicketStatistics(c *gin.Context) {
|
||||
stats.Reviewing = int(c.Count)
|
||||
case "executing":
|
||||
stats.Executing = int(c.Count)
|
||||
case "completed":
|
||||
case "success":
|
||||
stats.Completed = int(c.Count)
|
||||
case "failed":
|
||||
stats.Failed = int(c.Count)
|
||||
@@ -210,6 +211,25 @@ func CreateTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-create approval record: step 1, approver = instance owner or admin
|
||||
stepName := "审核"
|
||||
stepType := "audit"
|
||||
approverRole := "admin"
|
||||
// Find first admin as default approver
|
||||
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)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, ticket)
|
||||
}
|
||||
|
||||
@@ -278,13 +298,303 @@ func ExecuteTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check ticket status
|
||||
if ticket.Status != nil && *ticket.Status != "approved" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"})
|
||||
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)
|
||||
|
||||
// TODO: actual SQL execution
|
||||
// Execute SQL based on db_type
|
||||
var execResult string
|
||||
var execErr string
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Execution started", "ticket_id": ticket.ID})
|
||||
if ticket.DBType == "redis" {
|
||||
execErr = "Redis command execution not yet supported in ticket workflow"
|
||||
} 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 ""
|
||||
}
|
||||
Reference in new issue
Block a user