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:
logan committed 2026-05-26 20:40:45 +08:00
1 parent 9372053574
commit f2e93a09c0
5 files changed
+1577 -67

No files matched your search

+313 -3
View File
@@ -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 ""
}