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:
Logan.Li committed 2026-10-26 17:21:00 +08:00
1 parent d5b8b8116c
commit 16f1f7d65f
26 files changed
+916 -963

No files matched your search

+139 -130
View File
@@ -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)
}