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

+45 -44
View File
@@ -8,6 +8,7 @@ import (
"time"
"sql-platform/internal/ghost"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -37,18 +38,18 @@ func getGhostPath() string {
// getGhostConfig returns all ghost-related settings as a map for API responses
func getGhostConfig() gin.H {
return gin.H{
"enabled": isGhostEnabled(),
"default_mode": getSetting("ghost_default_mode", "dry_run"),
"osc_priority": getSetting("ghost_osc_priority", "native_first"),
"env_mode": getSetting("ghost_env_mode", "development"),
"cloud_type": getSetting("ghost_cloud_type", "self_hosted"),
"auto_trigger_rows": getSettingInt("ghost_auto_trigger_rows", 10),
"auto_trigger_ddl": getSetting("ghost_auto_trigger_ddl", "ADD COLUMN,MODIFY COLUMN,ADD INDEX"),
"chunk_size": getSettingInt("ghost_default_chunk_size", 1000),
"max_load": getSetting("ghost_max_load", "Threads_running=25"),
"critical_load": getSetting("ghost_critical_load", "Threads_running=50"),
"allow_on_master": getSetting("ghost_allow_on_master", "true") == "true",
"max_lag_millis": getSettingInt("ghost_max_lag_millis", 1500),
"enabled": isGhostEnabled(),
"default_mode": getSetting("ghost_default_mode", "dry_run"),
"osc_priority": getSetting("ghost_osc_priority", "native_first"),
"env_mode": getSetting("ghost_env_mode", "development"),
"cloud_type": getSetting("ghost_cloud_type", "self_hosted"),
"auto_trigger_rows": getSettingInt("ghost_auto_trigger_rows", 10),
"auto_trigger_ddl": getSetting("ghost_auto_trigger_ddl", "ADD COLUMN,MODIFY COLUMN,ADD INDEX"),
"chunk_size": getSettingInt("ghost_default_chunk_size", 1000),
"max_load": getSetting("ghost_max_load", "Threads_running=25"),
"critical_load": getSetting("ghost_critical_load", "Threads_running=50"),
"allow_on_master": getSetting("ghost_allow_on_master", "true") == "true",
"max_lag_millis": getSettingInt("ghost_max_lag_millis", 1500),
}
}
@@ -64,10 +65,10 @@ func getSettingInt(key string, fallback int) int {
// DDLAnalysis contains the analysis result of an ALTER statement
type DDLAnalysis struct {
DDLTypes []string // extracted DDL operation types
NeedsOSC bool // whether OSC tool is recommended
Reason string // explanation for the recommendation
RecommendedTool string // "native", "gh-ost", "pt-osc"
DDLTypes []string // extracted DDL operation types
NeedsOSC bool // whether OSC tool is recommended
Reason string // explanation for the recommendation
RecommendedTool string // "native", "gh-ost", "pt-osc"
}
// analyzeDDL inspects the ALTER statement to determine DDL types and OSC need
@@ -81,7 +82,7 @@ func analyzeDDL(alterStmt string) DDLAnalysis {
"ADD INDEX": "ADD INDEX", "DROP INDEX": "DROP INDEX",
"ADD UNIQUE": "ADD INDEX", "ADD PRIMARY KEY": "ADD INDEX",
"DROP PRIMARY KEY": "DROP INDEX",
"RENAME COLUMN": "CHANGE COLUMN", "ALGORITHM": "ALGORITHM",
"RENAME COLUMN": "CHANGE COLUMN", "ALGORITHM": "ALGORITHM",
}
for pattern, ddlType := range ddls {
if strings.Contains(upper, pattern) {
@@ -195,7 +196,7 @@ func GhOstAnalyzeDDL(c *gin.Context) {
Alter string `json:"alter"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -234,16 +235,16 @@ func GhOstAnalyzeDDL(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"enabled": isGhostEnabled(),
"ddl_types": analysis.DDLTypes,
"needs_osc": analysis.NeedsOSC,
"recommended_tool": analysis.RecommendedTool,
"reason": analysis.Reason,
"table_rows": rowCount,
"mysql_version": mysqlVersion,
"supports_instant": instantDDL,
"cloud_type": cloudType,
"config": getGhostConfig(),
"enabled": isGhostEnabled(),
"ddl_types": analysis.DDLTypes,
"needs_osc": analysis.NeedsOSC,
"recommended_tool": analysis.RecommendedTool,
"reason": analysis.Reason,
"table_rows": rowCount,
"mysql_version": mysqlVersion,
"supports_instant": instantDDL,
"cloud_type": cloudType,
"config": getGhostConfig(),
})
}
@@ -274,7 +275,7 @@ func getTableRowCount(instanceID int, database, table string) int {
// POST /api/v1/ghost/dry-run
func GhOstDryRun(c *gin.Context) {
if !isGhostEnabled() {
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"})
resp.Forbidden(c, "gh-ost 功能已禁用,请在系统设置中开启")
return
}
@@ -285,13 +286,13 @@ func GhOstDryRun(c *gin.Context) {
Alter string `json:"alter"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task.DryRun = true
@@ -309,7 +310,7 @@ func GhOstDryRun(c *gin.Context) {
// POST /api/v1/ghost/execute
func GhOstExecute(c *gin.Context) {
if !isGhostEnabled() {
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"})
resp.Forbidden(c, "gh-ost 功能已禁用,请在系统设置中开启")
return
}
@@ -321,13 +322,13 @@ func GhOstExecute(c *gin.Context) {
Force bool `json:"force"` // skip auto-trigger check
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task.DryRun = false
@@ -408,7 +409,7 @@ func GhOstStatus(c *gin.Context) {
taskID := c.Param("taskId")
status, result := ghost.Manager.GetTaskStatus(taskID)
if status == "" {
c.JSON(http.StatusNotFound, gin.H{"detail": "Task not found"})
resp.NotFound(c, "Task not found")
return
}
c.JSON(http.StatusOK, gin.H{
@@ -445,7 +446,7 @@ func ListAnnouncements(c *gin.Context) {
// GET /api/v1/query-histories
func ListQueryHistories(c *gin.Context) {
_, _, _ = c.GetInt("user_id"), c.GetString("username"), c.GetString("role")
var histories []model.QueryHistory
model.DB.Order("id DESC").Limit(100).Find(&histories)
c.JSON(http.StatusOK, histories)
@@ -471,7 +472,7 @@ func UpdateSettings(c *gin.Context) {
Value string `json:"value"`
}
if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
for _, item := range items {
@@ -650,7 +651,7 @@ func SeedAuditRules(c *gin.Context) {
dbType := c.Param("dbType")
rules, ok := defaultAuditRules[dbType]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的数据库类型: %s", dbType)})
resp.BadRequest(c, fmt.Sprintf("不支持的数据库类型: %s", dbType))
return
}
@@ -679,7 +680,7 @@ func SeedAuditRules(c *gin.Context) {
Value: &value,
}
if err := model.DB.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("创建规则失败: %s", err.Error())})
resp.InternalErr(c, err)
return
}
count++
@@ -692,12 +693,12 @@ func SeedAuditRules(c *gin.Context) {
func ToggleAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
resp.BadRequest(c, "无效的规则 ID")
return
}
var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
resp.NotFound(c, "规则不存在")
return
}
newEnabled := !*rule.IsEnabled
@@ -709,12 +710,12 @@ func ToggleAuditRule(c *gin.Context) {
func UpdateAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
resp.BadRequest(c, "无效的规则 ID")
return
}
var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
resp.NotFound(c, "规则不存在")
return
}
var body struct {
@@ -724,7 +725,7 @@ func UpdateAuditRule(c *gin.Context) {
Severity *string `json:"severity"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
updates := map[string]interface{}{}