feat(backend): unified response helpers, refresh token, security hardening
- new resp package (OK/Page/Fail/BadRequest/.../InternalErr): unified error
envelope {detail, code?}, sanitized 500s (full error logged server-side,
generic message to client - stops DB error leakage), pagination envelope
{items,total,page,page_size}; migrate 200+ inline c.JSON error sites
- refresh token: 7d rotation tokens (SHA-256 hashed at rest), public
POST /auth/refresh with single-use rotation, POST /auth/logout revokes
all user refresh tokens (endpoint previously missing - frontend 404)
- security: gh-ost command line password masked in task status payload;
Instance.MarshalJSON guarantees encrypted passwords never leave the API
(removes reliance on per-handler manual masking); production (DEBUG=false)
with default JWT secret now auto-generates and persists a random secret
- safe.Go recover wrapper for background goroutines (instance probe,
gh-ost async)
- delete dead service layer (zero callers)
- fix pre-existing go vet unkeyed bson.E literals
This commit is contained in:
1 parent
d5b8b8116c
commit
16f1f7d65f
26 files changed
+916
-963
No files matched your search
+139
-130
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"sql-platform/internal/ghost"
|
||||
"sql-platform/internal/handler/resp"
|
||||
"sql-platform/internal/middleware"
|
||||
"sql-platform/internal/model"
|
||||
|
||||
@@ -21,7 +22,7 @@ import (
|
||||
// SqlExecItem records the execution result of a single SQL statement
|
||||
type SqlExecItem struct {
|
||||
SQL string `json:"sql"`
|
||||
Status string `json:"status"` // success / fail
|
||||
Status string `json:"status"` // success / fail
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -39,15 +40,15 @@ type SqlExecResult struct {
|
||||
// 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":
|
||||
@@ -58,11 +59,11 @@ func TicketStatistics(c *gin.Context) {
|
||||
"applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)",
|
||||
userID, userID,
|
||||
)
|
||||
// admin sees everything
|
||||
// admin sees everything
|
||||
}
|
||||
|
||||
|
||||
query.Group("status").Scan(&counts)
|
||||
|
||||
|
||||
stats := model.TicketStatistics{}
|
||||
for _, c := range counts {
|
||||
switch c.Status {
|
||||
@@ -81,7 +82,7 @@ func TicketStatistics(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
stats.Total = stats.Pending + stats.Reviewing + stats.Executing + stats.Completed + stats.Failed + stats.Rejected
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
@@ -91,17 +92,21 @@ func ListTickets(c *gin.Context) {
|
||||
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 }
|
||||
|
||||
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 "admin":
|
||||
@@ -112,24 +117,24 @@ func ListTickets(c *gin.Context) {
|
||||
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"`
|
||||
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 {
|
||||
@@ -150,9 +155,9 @@ func ListTickets(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": limit,
|
||||
})
|
||||
}
|
||||
@@ -162,10 +167,10 @@ 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"})
|
||||
resp.NotFound(c, "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)
|
||||
@@ -191,11 +196,11 @@ func GetTicket(c *gin.Context) {
|
||||
}
|
||||
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
|
||||
@@ -203,7 +208,7 @@ func GetTicket(c *gin.Context) {
|
||||
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 {
|
||||
@@ -213,7 +218,7 @@ func GetTicket(c *gin.Context) {
|
||||
if err := model.DB.First(&inst, ticket.InstanceID).Error; err == nil {
|
||||
result["instance_name"] = inst.Name
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -223,7 +228,7 @@ func CreateTicket(c *gin.Context) {
|
||||
|
||||
// auditor cannot create tickets
|
||||
if userRole == "auditor" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审计员不能提交工单"})
|
||||
resp.Forbidden(c, "权限不足:审计员不能提交工单")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -240,9 +245,9 @@ func CreateTicket(c *gin.Context) {
|
||||
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()})
|
||||
resp.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,7 +257,7 @@ func CreateTicket(c *gin.Context) {
|
||||
readOnlyPrefixes := []string{"SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"}
|
||||
for _, prefix := range readOnlyPrefixes {
|
||||
if strings.HasPrefix(upperSQL, prefix) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能"})
|
||||
resp.BadRequest(c, "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -267,7 +272,7 @@ func CreateTicket(c *gin.Context) {
|
||||
}
|
||||
for _, ro := range redisReadOnlyCmds {
|
||||
if cmdUpper == ro {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper)})
|
||||
resp.BadRequest(c, fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -341,12 +346,12 @@ func CreateTicket(c *gin.Context) {
|
||||
input.WorkflowID = inst.WorkflowID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
status := "pending"
|
||||
priority := "medium"
|
||||
riskLevel := "low"
|
||||
execStrategy := "stop_on_error"
|
||||
|
||||
|
||||
if input.Priority != nil {
|
||||
priority = *input.Priority
|
||||
}
|
||||
@@ -356,11 +361,11 @@ func CreateTicket(c *gin.Context) {
|
||||
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,
|
||||
@@ -377,12 +382,12 @@ func CreateTicket(c *gin.Context) {
|
||||
ExecStrategy: &execStrategy,
|
||||
TicketNo: &ticketNo,
|
||||
}
|
||||
|
||||
|
||||
if err := model.DB.Create(&ticket).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
resp.InternalErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Create approval records based on workflow or default to admin
|
||||
hasAuditStep := false
|
||||
if input.WorkflowID != nil && *input.WorkflowID > 0 {
|
||||
@@ -408,11 +413,11 @@ func CreateTicket(c *gin.Context) {
|
||||
stepName := ws.Name
|
||||
stepType := ws.StepType
|
||||
approval := model.TicketApproval{
|
||||
TicketID: ticket.ID,
|
||||
StepNumber: ws.StepNumber,
|
||||
StepName: &stepName,
|
||||
StepType: &stepType,
|
||||
ApproverID: &uidCopy,
|
||||
TicketID: ticket.ID,
|
||||
StepNumber: ws.StepNumber,
|
||||
StepName: &stepName,
|
||||
StepType: &stepType,
|
||||
ApproverID: &uidCopy,
|
||||
}
|
||||
model.DB.Create(&approval)
|
||||
}
|
||||
@@ -476,13 +481,13 @@ func ApproveTicket(c *gin.Context) {
|
||||
|
||||
// Role check: admin/dba/developer can approve
|
||||
if userRole != "admin" && userRole != "dba" && userRole != "developer" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审核操作需要管理员、DBA 或开发者角色"})
|
||||
resp.Forbidden(c, "权限不足:审核操作需要管理员、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"})
|
||||
resp.NotFound(c, "Ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,7 +496,7 @@ func ApproveTicket(c *gin.Context) {
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
resp.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -501,7 +506,7 @@ func ApproveTicket(c *gin.Context) {
|
||||
var approval model.TicketApproval
|
||||
if err := model.DB.Where("ticket_id = ? AND approver_id = ? AND action IS NULL AND step_type != 'execute'", id, userID).First(&approval).Error; err != nil {
|
||||
// Already approved or no pending approval for this user
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "没有待处理的审批记录(你可能已经审批过了)"})
|
||||
resp.BadRequest(c, "没有待处理的审批记录(你可能已经审批过了)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -510,7 +515,7 @@ func ApproveTicket(c *gin.Context) {
|
||||
approval.ActualApproverID = &userID
|
||||
approval.CompletedAt = &now
|
||||
model.DB.Save(&approval)
|
||||
|
||||
|
||||
// Update ticket status
|
||||
newStatus := "approved"
|
||||
if input.Action == "reject" {
|
||||
@@ -524,11 +529,11 @@ func ApproveTicket(c *gin.Context) {
|
||||
}
|
||||
// If all audit steps done → "approved" (ready for execution)
|
||||
}
|
||||
|
||||
|
||||
ticket.Status = &newStatus
|
||||
ticket.ReviewTime = &now
|
||||
model.DB.Save(&ticket)
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "approval": approval})
|
||||
}
|
||||
|
||||
@@ -539,19 +544,19 @@ func ExecuteTicket(c *gin.Context) {
|
||||
// Role check: only admin/dba can execute
|
||||
_, _, userRole := middleware.GetCurrentUser(c)
|
||||
if userRole != "admin" && userRole != "dba" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:执行操作需要管理员或 DBA 角色"})
|
||||
resp.Forbidden(c, "权限不足:执行操作需要管理员或 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"})
|
||||
resp.NotFound(c, "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"})
|
||||
resp.BadRequest(c, "Ticket must be approved before execution")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -564,7 +569,7 @@ func ExecuteTicket(c *gin.Context) {
|
||||
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": "你不是此工单的执行人"})
|
||||
resp.Forbidden(c, "你不是此工单的执行人")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -587,7 +592,7 @@ func ExecuteTicket(c *gin.Context) {
|
||||
Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id).
|
||||
Count(&pendingAudit)
|
||||
if pendingAudit > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "还有未完成的审核步骤"})
|
||||
resp.BadRequest(c, "还有未完成的审核步骤")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -597,27 +602,27 @@ func ExecuteTicket(c *gin.Context) {
|
||||
Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id).
|
||||
Count(&rejectedAudit)
|
||||
if rejectedAudit > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "审核已被拒绝,无法执行"})
|
||||
resp.BadRequest(c, "审核已被拒绝,无法执行")
|
||||
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"})
|
||||
resp.BadRequest(c, "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
|
||||
@@ -716,31 +721,31 @@ func ExecuteTicket(c *gin.Context) {
|
||||
execTimeout := getSettingInt("executor_timeout", 300)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second)
|
||||
defer cancel()
|
||||
execStart := time.Now()
|
||||
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
|
||||
stmtMs := time.Since(execStart).Milliseconds()
|
||||
ghRes := SqlExecResult{
|
||||
Status: "success",
|
||||
TotalMs: stmtMs,
|
||||
StartTime: execStart.UTC().Format(time.RFC3339),
|
||||
EndTime: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if ghErr != nil {
|
||||
ghRes.Status = "failed"
|
||||
ghRes.Items = []SqlExecItem{{
|
||||
SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs,
|
||||
Error: fmt.Sprintf("%s\n%s", result.Error, ghErr.Error()),
|
||||
}}
|
||||
execErr = ghErr.Error()
|
||||
} else {
|
||||
ghRes.Items = []SqlExecItem{{
|
||||
SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs,
|
||||
Error: result.Output,
|
||||
}}
|
||||
}
|
||||
if jsonBytes, jsonErr := json.Marshal(ghRes); jsonErr == nil {
|
||||
execResult = string(jsonBytes)
|
||||
}
|
||||
execStart := time.Now()
|
||||
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
|
||||
stmtMs := time.Since(execStart).Milliseconds()
|
||||
ghRes := SqlExecResult{
|
||||
Status: "success",
|
||||
TotalMs: stmtMs,
|
||||
StartTime: execStart.UTC().Format(time.RFC3339),
|
||||
EndTime: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if ghErr != nil {
|
||||
ghRes.Status = "failed"
|
||||
ghRes.Items = []SqlExecItem{{
|
||||
SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs,
|
||||
Error: fmt.Sprintf("%s\n%s", result.Error, ghErr.Error()),
|
||||
}}
|
||||
execErr = ghErr.Error()
|
||||
} else {
|
||||
ghRes.Items = []SqlExecItem{{
|
||||
SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs,
|
||||
Error: result.Output,
|
||||
}}
|
||||
}
|
||||
if jsonBytes, jsonErr := json.Marshal(ghRes); jsonErr == nil {
|
||||
execResult = string(jsonBytes)
|
||||
}
|
||||
// Skip normal execution path
|
||||
goto executionDone
|
||||
}
|
||||
@@ -803,7 +808,7 @@ func ExecuteTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Split multi-statement by semicolons — wrap in transaction for atomic rollback
|
||||
// Split multi-statement by semicolons — wrap in transaction for atomic rollback
|
||||
execStart := time.Now()
|
||||
statements := splitSQL(ticket.SQLContent)
|
||||
execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)}
|
||||
@@ -853,8 +858,8 @@ func ExecuteTicket(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executionDone:
|
||||
|
||||
executionDone:
|
||||
// Update final status
|
||||
if execErr != "" {
|
||||
failStatus := "failed"
|
||||
@@ -866,7 +871,7 @@ func ExecuteTicket(c *gin.Context) {
|
||||
ticket.ExecuteResult = &execResult
|
||||
}
|
||||
model.DB.Save(&ticket)
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Execution completed", "ticket": ticket})
|
||||
}
|
||||
|
||||
@@ -930,75 +935,74 @@ func parseAlterTable(sql string) (string, string, error) {
|
||||
return tableName, alterClause, nil
|
||||
}
|
||||
|
||||
|
||||
// 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"})
|
||||
resp.NotFound(c, "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"})
|
||||
resp.Forbidden(c, "Only applicant or admin can cancel")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing" && *ticket.Status != "approved") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "只能撤销待审核、审核中或已通过(待执行)的工单"})
|
||||
resp.BadRequest(c, "只能撤销待审核、审核中或已通过(待执行)的工单")
|
||||
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"})
|
||||
resp.NotFound(c, "Ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if ticket.RollbackSQL == nil || *ticket.RollbackSQL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "No rollback SQL available"})
|
||||
resp.BadRequest(c, "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"})
|
||||
resp.BadRequest(c, "Instance not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if ticket.DBType == "redis" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Redis rollback not supported"})
|
||||
resp.BadRequest(c, "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)})
|
||||
resp.InternalErr(c, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
|
||||
// Execute rollback SQL
|
||||
statements := splitSQL(*ticket.RollbackSQL)
|
||||
var results []string
|
||||
@@ -1017,18 +1021,18 @@ func RollbackTicket(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -1037,10 +1041,10 @@ 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"})
|
||||
resp.NotFound(c, "Ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
type TimelineEntry struct {
|
||||
ID int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
@@ -1049,9 +1053,9 @@ func GetTicketTimeline(c *gin.Context) {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
|
||||
var timeline []TimelineEntry
|
||||
|
||||
|
||||
// 1. Created event
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
ID: 1,
|
||||
@@ -1060,7 +1064,7 @@ func GetTicketTimeline(c *gin.Context) {
|
||||
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)
|
||||
@@ -1082,7 +1086,7 @@ func GetTicketTimeline(c *gin.Context) {
|
||||
Timestamp: ts,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 3. Executed event
|
||||
if ticket.ExecuteTime != nil {
|
||||
execAction := "executed"
|
||||
@@ -1095,36 +1099,41 @@ func GetTicketTimeline(c *gin.Context) {
|
||||
ID: 99,
|
||||
Action: execAction,
|
||||
Timestamp: ticket.ExecuteTime.Format(time.RFC3339),
|
||||
Status: func() string { if ticket.Status != nil { return *ticket.Status }; return "" }(),
|
||||
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()})
|
||||
resp.InternalErr(c, err)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user