diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index cccd167..6a283ee 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -58,11 +58,15 @@ func main() { &model.ScheduledTask{}, &model.ScheduledTaskLog{}, &model.UserGroup{}, + &model.RefreshToken{}, ) // Seed default data (idempotent): users, workflow, settings seedAll(db) + // 治理默认 JWT 密钥(生产模式自动生成并持久化) + config.EnsureJWTSecret() + // Setup Gin r := gin.Default() r.Use(middleware.CORSMiddleware()) diff --git a/backend/config/config.go b/backend/config/config.go index 8257d35..d9f5832 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -1,19 +1,27 @@ package config import ( + "crypto/rand" + "encoding/hex" + "log" "os" "strconv" + "sql-platform/internal/model" + "github.com/joho/godotenv" ) +// DefaultJWTSecret 仅用于本地 DEBUG;生产模式(DEBUG=false)会被 EnsureJWTSecret 替换为随机密钥 +const DefaultJWTSecret = "sql-platform-secret-key-change-in-production" + type Config struct { - Port string - DBPath string - JWTSecret string - JWTExpire int // hours - GhOstPath string - Debug bool + Port string + DBPath string + JWTSecret string + JWTExpire int // hours + GhOstPath string + Debug bool } var C Config @@ -24,13 +32,49 @@ func Init() { C = Config{ Port: getEnv("PORT", "9000"), DBPath: getEnv("DB_PATH", "../backend/sqlite3.db"), - JWTSecret: getEnv("JWT_SECRET", "sql-platform-secret-key-change-in-production"), + JWTSecret: getEnv("JWT_SECRET", DefaultJWTSecret), JWTExpire: getEnvInt("JWT_EXPIRE_HOURS", 24), GhOstPath: getEnv("GH_OST_PATH", "gh-ost"), Debug: getEnvBool("DEBUG", true), } } +// EnsureJWTSecret 治理默认 JWT 密钥: +// - 显式配置了 JWT_SECRET(非默认值)→ 直接使用 +// - DEBUG 模式使用默认密钥 → 打印告警(开发便利优先) +// - 生产模式使用默认密钥 → 自动生成随机密钥并持久化到 system_settings(重启复用; +// 首次切换会使存量会话失效一次) +// +// 必须在 model.DB 初始化并完成 AutoMigrate 之后调用。 +func EnsureJWTSecret() { + if C.JWTSecret != DefaultJWTSecret { + return + } + if C.Debug { + log.Println("[WARN] 正在使用默认 JWT 密钥(DEBUG 模式);生产环境请设置 JWT_SECRET 环境变量") + return + } + + var s model.SystemSetting + if err := model.DB.Where("`key` = ?", "jwt_secret").Take(&s).Error; err == nil && s.Value != "" { + C.JWTSecret = s.Value + return + } + + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + log.Fatalf("生成随机 JWT 密钥失败: %v", err) + } + secret := hex.EncodeToString(buf) + if err := model.DB.Where(model.SystemSetting{Key: "jwt_secret"}). + Assign(model.SystemSetting{Value: secret, ValueType: "string", Group: "security", Label: "JWT Secret"}). + FirstOrCreate(&s).Error; err != nil { + log.Fatalf("持久化 JWT 密钥失败: %v", err) + } + C.JWTSecret = secret + log.Println("[INFO] 已自动生成随机 JWT 密钥并持久化(未显式配置 JWT_SECRET 的生产部署,存量会话将失效一次)") +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/backend/internal/ghost/ghost.go b/backend/internal/ghost/ghost.go index b80e1a6..744cfde 100644 --- a/backend/internal/ghost/ghost.go +++ b/backend/internal/ghost/ghost.go @@ -5,31 +5,34 @@ import ( "context" "fmt" "os/exec" + "regexp" "strings" "sync" "time" + + "sql-platform/internal/safe" ) // GhOstTask defines a gh-ost execution task type GhOstTask struct { - Host string `json:"host"` - Port int `json:"port,omitempty"` - User string `json:"user"` - Password string `json:"password"` - Database string `json:"database"` - Table string `json:"table"` - Alter string `json:"alter"` // e.g. "ADD COLUMN status INT NOT NULL DEFAULT 0" - ChunkSize int `json:"chunk_size,omitempty"` - MaxLoad string `json:"max_load,omitempty"` - CriticalLoad string `json:"critical_load,omitempty"` - MaxLagMillis int `json:"max_lag_millis,omitempty"` - AllowOnMaster bool `json:"allow_on_master"` - DryRun bool `json:"dry_run"` - Execute bool `json:"execute"` - ExactRowCount bool `json:"exact_row_count"` - PostponeCutOver bool `json:"postpone_cut_over"` - AliyunRDS bool `json:"aliyun_rds,omitempty"` // --aliyun-rds - AWSAurora bool `json:"aws_aurora,omitempty"` // --aws + Host string `json:"host"` + Port int `json:"port,omitempty"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + Table string `json:"table"` + Alter string `json:"alter"` // e.g. "ADD COLUMN status INT NOT NULL DEFAULT 0" + ChunkSize int `json:"chunk_size,omitempty"` + MaxLoad string `json:"max_load,omitempty"` + CriticalLoad string `json:"critical_load,omitempty"` + MaxLagMillis int `json:"max_lag_millis,omitempty"` + AllowOnMaster bool `json:"allow_on_master"` + DryRun bool `json:"dry_run"` + Execute bool `json:"execute"` + ExactRowCount bool `json:"exact_row_count"` + PostponeCutOver bool `json:"postpone_cut_over"` + AliyunRDS bool `json:"aliyun_rds,omitempty"` // --aliyun-rds + AWSAurora bool `json:"aws_aurora,omitempty"` // --aws } // GhOstResult holds the execution result @@ -121,7 +124,7 @@ func RunGhOst(ctx context.Context, task GhOstTask, ghOstPath string) (*GhOstResu err := cmd.Run() result := &GhOstResult{ - Command: "gh-ost " + strings.Join(args, " "), + Command: "gh-ost " + maskSecrets(strings.Join(args, " ")), Duration: time.Since(start).String(), Output: stdout.String(), } @@ -145,7 +148,7 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string Manager.tasks[taskID] = &GhOstResult{TaskID: taskID} Manager.mu.Unlock() - go func() { + run := func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() @@ -165,7 +168,7 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string result := &GhOstResult{ TaskID: taskID, - Command: "gh-ost " + strings.Join(args, " "), + Command: "gh-ost " + maskSecrets(strings.Join(args, " ")), Duration: time.Since(start).String(), Output: stdout.String(), Success: err == nil, @@ -183,11 +186,17 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string if cb != nil { cb(taskID, result.Output) } - }() + } + safe.Go("ghost:"+taskID, run) return taskID } +// maskSecrets 将命令行中的密码参数值替换为 ******,防止经任务状态接口回传前端 +func maskSecrets(cmdline string) string { + return regexp.MustCompile(`(--password=)([^ ]+)`).ReplaceAllString(cmdline, `$1******`) +} + // GetTaskStatus returns the status of an async task func (m *TaskManager) GetTaskStatus(taskID string) (string, *GhOstResult) { m.mu.RLock() diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index bb09485..cd61c52 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "sql-platform/internal/handler/resp" "sql-platform/internal/middleware" "sql-platform/internal/model" @@ -22,7 +23,7 @@ import ( func Login(c *gin.Context) { var req model.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "username and password required"}) + resp.BadRequest(c, "username and password required") return } @@ -34,7 +35,7 @@ func Login(c *gin.Context) { maxAttempts := getSettingInt("login_max_attempts", 5) lockMin := getSettingInt("login_lockout_minutes", 30) remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1 - c.JSON(http.StatusTooManyRequests, gin.H{"detail": fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), "retry_after_minutes": remaining}) + resp.TooManyRequests(c, fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), remaining) return } // Lock expired → reset @@ -47,7 +48,7 @@ func Login(c *gin.Context) { if err == nil { // ── 本地用户存在 ── if user.IsActive != nil && !*user.IsActive { - c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"}) + resp.Forbidden(c, "Account is disabled") return } @@ -60,7 +61,7 @@ func Login(c *gin.Context) { if ldapErr != nil || ldapInfo == nil { // Both local and LDAP failed → record failed attempt handleFailedLogin(&user) - c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) + resp.Unauthorized(c, "Invalid username or password") return } // LDAP OK — update local user info @@ -81,7 +82,7 @@ func Login(c *gin.Context) { // ── 本地不存在该用户,尝试 LDAP 认证 ── _, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) if ldapErr != nil || ldapInfo == nil { - c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) + resp.Unauthorized(c, "Invalid username or password") return } // LDAP 认证成功,自动创建或恢复本地用户 @@ -102,7 +103,7 @@ func Login(c *gin.Context) { // FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突) result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser) if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user: " + result.Error.Error()}) + resp.InternalErr(c, result.Error) return } // 更新已有用户的 display_name / email(LDAP 信息可能变更) @@ -119,7 +120,7 @@ func Login(c *gin.Context) { } user = newUser } else { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"}) + resp.InternalMsg(c, "Database error") return } @@ -129,7 +130,7 @@ func Login(c *gin.Context) { user.LockedUntil = nil token, err := middleware.GenerateToken(&user) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"}) + resp.InternalMsg(c, "Failed to generate token") return } @@ -137,13 +138,53 @@ func Login(c *gin.Context) { user.LastLogin = &now model.DB.Save(&user) + // 签发 refresh token(失败不阻塞登录,仅记录) + refreshToken, refreshErr := middleware.GenerateRefreshToken(user.ID) + if refreshErr != nil { + fmt.Printf("generate refresh token failed: %v\n", refreshErr) + } + c.JSON(http.StatusOK, model.LoginResponse{ - AccessToken: token, - TokenType: "bearer", - User: user, + AccessToken: token, + RefreshToken: refreshToken, + TokenType: "bearer", + User: user, }) } +// POST /api/v1/auth/refresh — 用 refresh_token 轮换新的 token 对 +func RefreshToken(c *gin.Context) { + var req struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + resp.BadRequest(c, "refresh_token required") + return + } + user, newRefresh, err := middleware.ConsumeRefreshToken(req.RefreshToken) + if err != nil { + resp.Fail(c, http.StatusUnauthorized, "登录已过期,请重新登录") + return + } + access, err := middleware.GenerateToken(user) + if err != nil { + resp.InternalErr(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "access_token": access, + "refresh_token": newRefresh, + "token_type": "bearer", + }) +} + +// POST /api/v1/auth/logout — 撤销当前用户全部 refresh token +func Logout(c *gin.Context) { + userID, _, _ := middleware.GetCurrentUser(c) + middleware.RevokeUserRefreshTokens(userID) + resp.Message(c, "Logged out") +} + // LDAPAuthInfo holds extracted user info from LDAP type LDAPAuthInfo struct { Username string @@ -258,7 +299,7 @@ func GetMe(c *gin.Context) { userID, _, _ := middleware.GetCurrentUser(c) var user model.User if err := model.DB.First(&user, userID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"}) + resp.NotFound(c, "User not found") return } c.JSON(http.StatusOK, user) @@ -282,13 +323,13 @@ func CreateUser(c *gin.Context) { Role string `json:"role" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"}) + resp.InternalMsg(c, "Failed to hash password") return } @@ -305,9 +346,9 @@ func CreateUser(c *gin.Context) { if err := model.DB.Create(&user).Error; err != nil { if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") { - c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"}) + resp.BadRequest(c, "Username already exists") } else { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) } return } @@ -320,13 +361,13 @@ func UpdateUser(c *gin.Context) { id := c.Param("id") var user model.User if err := model.DB.First(&user, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"}) + resp.NotFound(c, "User not found") return } var input map[string]interface{} if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -337,7 +378,7 @@ func UpdateUser(c *gin.Context) { delete(input, "password") if err := model.DB.Model(&user).Updates(input).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -350,11 +391,11 @@ func DeleteUser(c *gin.Context) { id := c.Param("id") // Protect admin (id=1) and dba (id=2) from being deleted if id == "1" || id == "2" { - c.JSON(http.StatusForbidden, gin.H{"detail": "Cannot delete system reserved users"}) + resp.Forbidden(c, "Cannot delete system reserved users") return } if err := model.DB.Delete(&model.User{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } c.JSON(http.StatusOK, gin.H{"message": "User deleted"}) diff --git a/backend/internal/handler/dashboard.go b/backend/internal/handler/dashboard.go index 162dec4..ccff590 100644 --- a/backend/internal/handler/dashboard.go +++ b/backend/internal/handler/dashboard.go @@ -32,19 +32,19 @@ func DashboardStats(c *gin.Context) { model.DB.Model(&model.Ticket{}).Select("db_type as db_type, count(*) as count").Group("db_type").Scan(&typeDist) c.JSON(http.StatusOK, gin.H{ - "ticket_total": ticketTotal, - "ticket_pending": ticketPending, - "ticket_month": ticketTotal, // 简化 - "ticket_executing": ticketExecuting, - "instance_total": instanceTotal, - "instance_online": instanceOnline, - "user_total": userTotal, - "queries_today": queriesToday, + "ticket_total": ticketTotal, + "ticket_pending": ticketPending, + "ticket_month": ticketTotal, // 简化 + "ticket_executing": ticketExecuting, + "instance_total": instanceTotal, + "instance_online": instanceOnline, + "user_total": userTotal, + "queries_today": queriesToday, "type_distribution": typeDist, "query_stats": gin.H{ - "total": queriesToday, - "avg_time_ms": 0, - "slow_count": 0, + "total": queriesToday, + "avg_time_ms": 0, + "slow_count": 0, "type_distribution": typeDist, }, }) diff --git a/backend/internal/handler/ghost.go b/backend/internal/handler/ghost.go index 33a59dc..f05d88b 100644 --- a/backend/internal/handler/ghost.go +++ b/backend/internal/handler/ghost.go @@ -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{}{} diff --git a/backend/internal/handler/instance.go b/backend/internal/handler/instance.go index b12c710..b43883d 100644 --- a/backend/internal/handler/instance.go +++ b/backend/internal/handler/instance.go @@ -5,8 +5,10 @@ import ( "strconv" "strings" + "sql-platform/internal/handler/resp" "sql-platform/internal/middleware" "sql-platform/internal/model" + "sql-platform/internal/safe" "github.com/gin-gonic/gin" ) @@ -35,14 +37,11 @@ func ListInstances(c *gin.Context) { } if err := query.Find(&instances).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } - // mask passwords in response - for i := range instances { - instances[i].Password = "" - } + // 密码出站遮蔽由 Instance.MarshalJSON 统一收口 c.JSON(http.StatusOK, instances) } @@ -51,10 +50,9 @@ func GetInstance(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } - instance.Password = "******" c.JSON(http.StatusOK, instance) } @@ -62,7 +60,7 @@ func GetInstance(c *gin.Context) { func CreateInstance(c *gin.Context) { var instance model.Instance if err := c.ShouldBindJSON(&instance); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } // Encrypt password before storing @@ -74,11 +72,11 @@ func CreateInstance(c *gin.Context) { } } if err := model.DB.Create(&instance).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } // Auto-detect version, cluster_mode and status after creation - go func() { + probe := func() { result := testDBConnectionFull(&instance) updates := map[string]interface{}{} if result.Success { @@ -95,8 +93,8 @@ func CreateInstance(c *gin.Context) { if len(updates) > 0 { model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) } - }() - instance.Password = "******" + } + safe.Go("instance-probe:"+strconv.Itoa(instance.ID), probe) c.JSON(http.StatusCreated, instance) } @@ -105,13 +103,13 @@ func UpdateInstance(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } var input map[string]interface{} if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -130,12 +128,11 @@ func UpdateInstance(c *gin.Context) { } if err := model.DB.Model(&instance).Updates(input).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } model.DB.First(&instance, id) - instance.Password = "******" c.JSON(http.StatusOK, instance) } @@ -143,7 +140,7 @@ func UpdateInstance(c *gin.Context) { func DeleteInstance(c *gin.Context) { id := c.Param("id") if err := model.DB.Delete(&model.Instance{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } c.JSON(http.StatusOK, gin.H{"message": "Instance deleted"}) @@ -154,7 +151,7 @@ func TestConnection(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -209,7 +206,7 @@ func InstanceMetrics(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 9443686..7357442 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -5,6 +5,7 @@ import ( "net/http" "reflect" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "bytes" @@ -28,7 +29,7 @@ func DetailListDatabases(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -39,14 +40,14 @@ func DetailListDatabases(c *gin.Context) { db, err := getDBConnection(&instance, "") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() rows, err := db.Query("SHOW DATABASES") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer rows.Close() @@ -72,7 +73,7 @@ func DetailListTables(c *gin.Context) { dbName := c.Query("database") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -87,20 +88,20 @@ func DetailListTables(c *gin.Context) { } } if dbName == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"}) + resp.BadRequest(c, "database parameter required") return } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() rows, err := db.Query("SHOW TABLE STATUS") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer rows.Close() @@ -139,7 +140,7 @@ func DetailDbSchema(c *gin.Context) { dbName := c.Query("database") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -154,13 +155,13 @@ func DetailDbSchema(c *gin.Context) { } } if dbName == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"}) + resp.BadRequest(c, "database parameter required") return } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() @@ -168,7 +169,7 @@ func DetailDbSchema(c *gin.Context) { // Get all tables and their columns tableRows, err := db.Query("SHOW TABLES") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer tableRows.Close() @@ -207,7 +208,7 @@ func DetailTableDetail(c *gin.Context) { tableName := c.Query("table") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -217,13 +218,13 @@ func DetailTableDetail(c *gin.Context) { } } if dbName == "" || tableName == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "database and table parameters required"}) + resp.BadRequest(c, "database and table parameters required") return } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() @@ -235,7 +236,7 @@ func DetailTableDetail(c *gin.Context) { // Get columns colRows, err := db.Query("SHOW FULL COLUMNS FROM `" + tableName + "`") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer colRows.Close() @@ -306,7 +307,7 @@ func DetailProcesslist(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -321,14 +322,14 @@ func DetailProcesslist(c *gin.Context) { } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() rows, err := db.Query("SHOW PROCESSLIST") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer rows.Close() @@ -365,7 +366,7 @@ func DetailStatus(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -383,14 +384,14 @@ func DetailStatus(c *gin.Context) { } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() rows, err := db.Query("SHOW GLOBAL STATUS") if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer rows.Close() @@ -429,7 +430,7 @@ func DetailKillProcess(c *gin.Context) { pid := c.Param("pid") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -439,14 +440,14 @@ func DetailKillProcess(c *gin.Context) { } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() _, err = db.Exec("KILL " + pid) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Kill failed: %v", err)}) + resp.InternalErr(c, err) return } c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"}) @@ -457,7 +458,7 @@ func DetailListUsers(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -472,7 +473,7 @@ func DetailListUsers(c *gin.Context) { } db, err := getDBConnection(&instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + resp.InternalErr(c, err) return } defer db.Close() @@ -514,11 +515,11 @@ func ExecuteQuery(c *gin.Context) { Offset int `json:"offset"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } if input.InstanceID == 0 || input.SQL == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"}) + resp.BadRequest(c, "instance_id and sql are required") return } // Read query limits from platform settings @@ -538,7 +539,7 @@ func ExecuteQuery(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -607,7 +608,7 @@ func ExecuteQuery(c *gin.Context) { c.JSON(http.StatusOK, result) default: - c.JSON(http.StatusBadRequest, gin.H{"detail": "Unsupported database type: " + instance.DBType}) + resp.BadRequest(c, "Unsupported database type: "+instance.DBType) } } @@ -1247,13 +1248,13 @@ func executeMongoQuery(instance *model.Instance, dbName string, queryStr string, if pipeline != nil { // Aggregation — add $facet for count + data facetStage := bson.D{ - {"$facet", bson.D{ - {"data", bson.A{ - bson.D{{"$skip", offset}}, - bson.D{{"$limit", limit}}, + {Key: "$facet", Value: bson.D{ + {Key: "data", Value: bson.A{ + bson.D{{Key: "$skip", Value: offset}}, + bson.D{{Key: "$limit", Value: limit}}, }}, - {"count", bson.A{ - bson.D{{"$count", "total"}}, + {Key: "count", Value: bson.A{ + bson.D{{Key: "$count", Value: "total"}}, }}, }}, } @@ -1404,11 +1405,11 @@ func ExportQueryCSV(c *gin.Context) { Limit int `json:"limit"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } if input.InstanceID == 0 || input.SQL == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"}) + resp.BadRequest(c, "instance_id and sql are required") return } if input.Limit <= 0 { @@ -1420,7 +1421,7 @@ func ExportQueryCSV(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } @@ -1438,7 +1439,7 @@ func ExportQueryCSV(c *gin.Context) { case "mongodb", "mongo": exportMongoCSV(c, &instance, input.Database, input.SQL, input.Limit) default: - c.JSON(http.StatusBadRequest, gin.H{"detail": "CSV export not supported for " + instance.DBType}) + resp.BadRequest(c, "CSV export not supported for "+instance.DBType) } } diff --git a/backend/internal/handler/instance_extra.go b/backend/internal/handler/instance_extra.go index ac0012d..5c009ce 100644 --- a/backend/internal/handler/instance_extra.go +++ b/backend/internal/handler/instance_extra.go @@ -3,6 +3,7 @@ package handler import ( "net/http" + "sql-platform/internal/handler/resp" "sql-platform/internal/middleware" "sql-platform/internal/model" @@ -57,7 +58,7 @@ func ListDatabases(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } databases, err := listDatabases(&instance) @@ -77,7 +78,7 @@ func ListTables(c *gin.Context) { dbName := c.Param("db") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } tables, err := listTables(&instance, dbName) @@ -102,7 +103,7 @@ func TestNewConnection(c *gin.Context) { Database string `json:"database"` } if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request"}) + resp.BadRequest(c, "Invalid request") return } diff --git a/backend/internal/handler/instance_group.go b/backend/internal/handler/instance_group.go index bcffce1..4343999 100644 --- a/backend/internal/handler/instance_group.go +++ b/backend/internal/handler/instance_group.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -17,7 +18,7 @@ func GetInstanceGroup(c *gin.Context) { id := c.Param("id") var group model.InstanceGroup if err := model.DB.First(&group, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance group not found"}) + resp.NotFound(c, "Instance group not found") return } c.JSON(http.StatusOK, enrichGroup(group)) @@ -34,7 +35,7 @@ func CreateInstanceGroup(c *gin.Context) { WriteInstanceIDs []int `json:"write_instance_ids"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -58,7 +59,7 @@ func CreateInstanceGroup(c *gin.Context) { IsActive: &isActive, } if err := model.DB.Create(&group).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -83,7 +84,7 @@ func UpdateInstanceGroup(c *gin.Context) { gid, _ := strconv.Atoi(id) var group model.InstanceGroup if err := model.DB.First(&group, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance group not found"}) + resp.NotFound(c, "Instance group not found") return } @@ -100,7 +101,7 @@ func UpdateInstanceGroup(c *gin.Context) { WriteInstanceIDs *[]int `json:"write_instance_ids"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -132,7 +133,7 @@ func UpdateInstanceGroup(c *gin.Context) { if len(updates) > 0 { if err := model.DB.Model(&group).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } } @@ -175,7 +176,7 @@ func UpdateInstanceGroup(c *gin.Context) { func DeleteInstanceGroup(c *gin.Context) { id := c.Param("id") if err := model.DB.Delete(&model.InstanceGroup{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } // also clean up members @@ -191,7 +192,7 @@ func AssignGroupInstances(c *gin.Context) { AccessType string `json:"access_type"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } if input.AccessType == "" { @@ -240,7 +241,7 @@ func AssignGroupUsers(c *gin.Context) { Permissions []string `json:"permissions"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -278,7 +279,7 @@ func UpdateUserGroups(c *gin.Context) { GroupIDs []int `json:"group_ids"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } diff --git a/backend/internal/handler/ldap.go b/backend/internal/handler/ldap.go index 0e4aa46..fca4cb6 100644 --- a/backend/internal/handler/ldap.go +++ b/backend/internal/handler/ldap.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -46,7 +47,7 @@ func GetLDAPConfig(c *gin.Context) { func UpdateLDAPConfig(c *gin.Context) { var input map[string]interface{} if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -84,11 +85,23 @@ func UpdateLDAPConfig(c *gin.Context) { if len(configs) == 0 { // Create new config cfg := model.LDAPConfig{ - LDAPHost: getStr("ldap_host"), - AdminDN: getStr("admin_dn"), - BaseDN: getStr("base_dn"), - SearchFilter: func() *string { s := getStr("search_filter"); if s != "" { return &s }; return nil }(), - UserMapping: func() *string { s := userMappingStr; if s != "" { return &s }; return nil }(), + LDAPHost: getStr("ldap_host"), + AdminDN: getStr("admin_dn"), + BaseDN: getStr("base_dn"), + SearchFilter: func() *string { + s := getStr("search_filter") + if s != "" { + return &s + } + return nil + }(), + UserMapping: func() *string { + s := userMappingStr + if s != "" { + return &s + } + return nil + }(), } if port := getInt("ldap_port"); port > 0 { cfg.LDAPPort = &port @@ -149,13 +162,13 @@ func TestLDAPConnection(c *gin.Context) { TestPassword string `json:"test_password"` } if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } var cfg model.LDAPConfig if err := model.DB.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "LDAP 配置不存在,请先保存配置"}) + resp.NotFound(c, "LDAP 配置不存在,请先保存配置") return } if cfg.LDAPHost == "" { @@ -342,17 +355,17 @@ func ListLDAPUsers(c *gin.Context) { } type LdapUser struct { - DN string `json:"dn"` - UID string `json:"uid"` - CN string `json:"cn"` + DN string `json:"dn"` + UID string `json:"uid"` + CN string `json:"cn"` Mail string `json:"mail"` } var users []LdapUser for _, e := range sr.Entries { users = append(users, LdapUser{ - DN: e.DN, - UID: e.GetAttributeValue("uid"), - CN: e.GetAttributeValue("cn"), + DN: e.DN, + UID: e.GetAttributeValue("uid"), + CN: e.GetAttributeValue("cn"), Mail: e.GetAttributeValue("mail"), }) } @@ -367,26 +380,26 @@ func CreateLDAPOU(c *gin.Context) { Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil || req.OuName == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 ou_name 参数"}) + resp.BadRequest(c, "缺少 ou_name 参数") return } var cfg model.LDAPConfig if err := model.DB.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) + resp.BadRequest(c, "LDAP 配置不存在") return } conn, err := dialLDAP(&cfg) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) + resp.BadRequest(c, "LDAP 连接失败: "+err.Error()) return } defer conn.Close() adminPw, _ := decryptPassword(cfg.AdminPassword) if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) + resp.BadRequest(c, "LDAP 管理员绑定失败") return } @@ -399,7 +412,7 @@ func CreateLDAPOU(c *gin.Context) { } if err := conn.Add(addReq); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "创建 OU 失败: " + err.Error()}) + resp.BadRequest(c, "创建 OU 失败: "+err.Error()) return } @@ -416,13 +429,13 @@ func CreateLDAPUser(c *gin.Context) { OuDn string `json:"ou_dn"` } if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 username 或 password 参数"}) + resp.BadRequest(c, "缺少 username 或 password 参数") return } var cfg model.LDAPConfig if err := model.DB.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) + resp.BadRequest(c, "LDAP 配置不存在") return } @@ -434,14 +447,14 @@ func CreateLDAPUser(c *gin.Context) { conn, err := dialLDAP(&cfg) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) + resp.BadRequest(c, "LDAP 连接失败: "+err.Error()) return } defer conn.Close() adminPw, _ := decryptPassword(cfg.AdminPassword) if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) + resp.BadRequest(c, "LDAP 管理员绑定失败") return } @@ -475,7 +488,7 @@ func CreateLDAPUser(c *gin.Context) { } if err := conn.Add(addReq); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "创建用户失败: " + err.Error()}) + resp.BadRequest(c, "创建用户失败: "+err.Error()) return } @@ -496,20 +509,20 @@ func BatchImportLDAP(c *gin.Context) { var cfg model.LDAPConfig if err := model.DB.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) + resp.BadRequest(c, "LDAP 配置不存在") return } conn, err := dialLDAP(&cfg) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) + resp.BadRequest(c, "LDAP 连接失败: "+err.Error()) return } defer conn.Close() adminPw, _ := decryptPassword(cfg.AdminPassword) if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) + resp.BadRequest(c, "LDAP 管理员绑定失败") return } @@ -534,7 +547,7 @@ func BatchImportLDAP(c *gin.Context) { []string{"uid", "cn", "mail"}, nil, )) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 搜索失败: " + err.Error()}) + resp.BadRequest(c, "LDAP 搜索失败: "+err.Error()) return } @@ -551,9 +564,14 @@ func BatchImportLDAP(c *gin.Context) { if len(req.UserIDs) > 0 { found := false for _, id := range req.UserIDs { - if id == uid { found = true; break } + if id == uid { + found = true + break + } + } + if !found { + continue } - if !found { continue } } cn := entry.GetAttributeValue("cn") @@ -564,8 +582,12 @@ func BatchImportLDAP(c *gin.Context) { if err == nil { // Update existing user updates := map[string]interface{}{} - if cn != "" && cn != existing.DisplayName { updates["display_name"] = cn } - if mail != "" && (existing.Email == nil || *existing.Email != mail) { updates["email"] = mail } + if cn != "" && cn != existing.DisplayName { + updates["display_name"] = cn + } + if mail != "" && (existing.Email == nil || *existing.Email != mail) { + updates["email"] = mail + } if len(updates) > 0 { model.DB.Model(&existing).Updates(updates) } @@ -582,7 +604,9 @@ func BatchImportLDAP(c *gin.Context) { IsActive: &isActive, HashedPassword: "-", } - if mail != "" { newUser.Email = &mail } + if mail != "" { + newUser.Email = &mail + } if err := model.DB.Create(&newUser).Error; err != nil { results = append(results, map[string]string{"uid": uid, "status": "error: " + err.Error()}) skipped++ @@ -606,31 +630,31 @@ func DeleteLDAPUser(c *gin.Context) { DN string `json:"dn" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 dn 参数"}) + resp.BadRequest(c, "缺少 dn 参数") return } var cfg model.LDAPConfig if err := model.DB.Order("id").First(&cfg).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) + resp.BadRequest(c, "LDAP 配置不存在") return } conn, err := dialLDAP(&cfg) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) + resp.BadRequest(c, "LDAP 连接失败: "+err.Error()) return } defer conn.Close() adminPw, _ := decryptPassword(cfg.AdminPassword) if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) + resp.BadRequest(c, "LDAP 管理员绑定失败") return } if err := conn.Del(ldap.NewDelRequest(req.DN, nil)); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "删除用户失败: " + err.Error()}) + resp.BadRequest(c, "删除用户失败: "+err.Error()) return } diff --git a/backend/internal/handler/notification.go b/backend/internal/handler/notification.go index 1fc3ba0..c578586 100644 --- a/backend/internal/handler/notification.go +++ b/backend/internal/handler/notification.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -63,11 +64,11 @@ func CreateNotification(c *gin.Context) { IsEnabled bool `json:"is_enabled"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"}) + resp.BadRequest(c, "参数错误") return } if input.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "名称不能为空"}) + resp.BadRequest(c, "名称不能为空") return } cfgBytes, _ := json.Marshal(input.Config) @@ -79,7 +80,7 @@ func CreateNotification(c *gin.Context) { IsEnabled: &enabled, } if err := model.DB.Create(&ch).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"}) + resp.InternalMsg(c, "创建失败") return } c.JSON(http.StatusOK, toChannelResponse(ch)) @@ -90,12 +91,12 @@ func UpdateNotification(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var ch model.NotificationChannel if err := model.DB.First(&ch, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"}) + resp.NotFound(c, "通知渠道不存在") return } var input map[string]interface{} if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"}) + resp.BadRequest(c, "参数错误") return } if name, ok := input["name"].(string); ok { @@ -127,7 +128,7 @@ func TestNotificationSend(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var ch model.NotificationChannel if err := model.DB.First(&ch, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"}) + resp.NotFound(c, "通知渠道不存在") return } @@ -135,7 +136,7 @@ func TestNotificationSend(c *gin.Context) { _ = json.Unmarshal([]byte(ch.Config), &cfg) webhookURL, _ := cfg["webhook_url"].(string) if webhookURL == "" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "Webhook URL 未配置"}) + resp.BadRequest(c, "Webhook URL 未配置") return } @@ -150,12 +151,12 @@ func TestNotificationSend(c *gin.Context) { secret, _ := cfg["secret"].(string) sendErr = sendDingtalkWebhook(webhookURL, secret, title, body) default: - c.JSON(http.StatusBadRequest, gin.H{"detail": "不支持的通知类型: " + ch.ChannelType}) + resp.BadRequest(c, "不支持的通知类型: "+ch.ChannelType) return } if sendErr != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()}) + resp.InternalErr(c, sendErr) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试消息已发送"}) diff --git a/backend/internal/handler/preferences.go b/backend/internal/handler/preferences.go index 48d1f94..bed175e 100644 --- a/backend/internal/handler/preferences.go +++ b/backend/internal/handler/preferences.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "sql-platform/internal/handler/resp" "sql-platform/internal/middleware" "sql-platform/internal/model" @@ -14,7 +15,7 @@ func GetUserPreferences(c *gin.Context) { userID, _, _ := middleware.GetCurrentUser(c) var user model.User if err := model.DB.First(&user, userID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"}) + resp.NotFound(c, "User not found") return } @@ -31,7 +32,7 @@ func UpdateUserPreferences(c *gin.Context) { var prefs map[string]string if err := c.ShouldBindJSON(&prefs); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -40,7 +41,7 @@ func UpdateUserPreferences(c *gin.Context) { result := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("preferences", str) if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": result.Error.Error()}) + resp.InternalErr(c, result.Error) return } diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index 7668169..6fbbec6 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -384,13 +385,13 @@ func RedisInfo(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, 0) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -398,7 +399,7 @@ func RedisInfo(c *gin.Context) { ctx := context.Background() info, err := client.Info(ctx).Result() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -460,13 +461,13 @@ func RedisDbsize(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -474,7 +475,7 @@ func RedisDbsize(c *gin.Context) { ctx := context.Background() dbSize, err := client.DBSize(ctx).Result() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } c.JSON(http.StatusOK, gin.H{"db_size": dbSize}) @@ -510,13 +511,13 @@ func RedisScanKeys(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -549,7 +550,7 @@ func RedisScanKeys(c *gin.Context) { allKeys, err := client.ScanAllKeys(ctx, pattern, count) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -586,13 +587,13 @@ func RedisGetKeyDetail(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -636,13 +637,13 @@ func RedisSlowlog(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, 0) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -650,7 +651,7 @@ func RedisSlowlog(c *gin.Context) { ctx := context.Background() logs, err := client.SlowLogGet(ctx, int64(count)).Result() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -675,13 +676,13 @@ func RedisClients(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, 0) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -689,7 +690,7 @@ func RedisClients(c *gin.Context) { ctx := context.Background() clientList, err := client.ClientList(ctx).Result() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -729,19 +730,19 @@ func RedisExecuteCommand(c *gin.Context) { Args []string `json:"args"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -760,7 +761,7 @@ func RedisExecuteCommand(c *gin.Context) { } if !readOnly[cmd] { - c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"}) + resp.Forbidden(c, "Only read-only commands allowed via query interface") return } @@ -803,19 +804,19 @@ func RedisCreateKey(c *gin.Context) { TTL int `json:"ttl"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -823,7 +824,7 @@ func RedisCreateKey(c *gin.Context) { ctx := context.Background() if err := writeRedisKeyValue(ctx, client, input.Key, input.Type, input.Value); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -850,30 +851,30 @@ func RedisUpdateKey(c *gin.Context) { TTL int `json:"ttl"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() if err := client.Del(ctx, key).Err(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } if err := writeRedisKeyValue(ctx, client, key, input.Type, input.Value); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } if input.TTL > 0 { @@ -895,13 +896,13 @@ func RedisDeleteKey(c *gin.Context) { var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } defer client.Close() @@ -909,7 +910,7 @@ func RedisDeleteKey(c *gin.Context) { ctx := context.Background() deleted, err := client.Del(ctx, key).Result() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } c.JSON(http.StatusOK, gin.H{"message": "Key deleted", "deleted": deleted}) @@ -920,7 +921,7 @@ func CloneWorkflow(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var wf model.WorkflowDefinition if err := model.DB.First(&wf, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"}) + resp.NotFound(c, "Workflow not found") return } // clone diff --git a/backend/internal/handler/resp/resp.go b/backend/internal/handler/resp/resp.go new file mode 100644 index 0000000..099371c --- /dev/null +++ b/backend/internal/handler/resp/resp.go @@ -0,0 +1,70 @@ +// Package resp 统一 API 响应助手(渐进式契约统一): +// - 成功:直接返回业务数据;分页统一 {items, total, page, page_size} 信封 +// - 失败:HTTP 状态码语义保持不变,错误体 {detail, code?}(兼容既有前端与测试) +// - 内部错误:服务端记录完整 error,客户端只收通用文案,杜绝 DB 错误原文泄漏 +// +// 新代码禁止直接 c.JSON 错误响应;存量调用点在迭代中逐步迁移到本包。 +package resp + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +// OK 200:直接返回业务数据 +func OK(c *gin.Context, data any) { + c.JSON(http.StatusOK, data) +} + +// Created 201:资源创建成功 +func Created(c *gin.Context, data any) { + c.JSON(http.StatusCreated, data) +} + +// Page 200:统一分页信封 {items, total, page, page_size} +func Page(c *gin.Context, items any, total int64, page, pageSize int) { + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// Message 200:仅含 {message} 的轻量确认(删除/测试类端点) +func Message(c *gin.Context, msg string) { + c.JSON(http.StatusOK, gin.H{"message": msg}) +} + +// Fail 通用失败:{detail} +func Fail(c *gin.Context, status int, detail string) { + c.JSON(status, gin.H{"detail": detail}) +} + +// FailCode 失败并携带机器可读错误码:{detail, code} +func FailCode(c *gin.Context, status int, code, detail string) { + c.JSON(status, gin.H{"detail": detail, "code": code}) +} + +func BadRequest(c *gin.Context, detail string) { Fail(c, http.StatusBadRequest, detail) } +func Unauthorized(c *gin.Context, detail string) { Fail(c, http.StatusUnauthorized, detail) } +func Forbidden(c *gin.Context, detail string) { Fail(c, http.StatusForbidden, detail) } +func NotFound(c *gin.Context, detail string) { Fail(c, http.StatusNotFound, detail) } + +// InternalErr 500:服务端 log 完整错误,客户端只收通用文案(防 DB/内部错误原文泄漏) +func InternalErr(c *gin.Context, err error) { + log.Printf("[resp] internal error on %s %s: %v", c.Request.Method, c.Request.URL.Path, err) + c.JSON(http.StatusInternalServerError, gin.H{"detail": "服务器内部错误,请稍后重试", "code": "internal"}) +} + +// InternalMsg 500:无底层 error、需要给用户可读文案时使用 +func InternalMsg(c *gin.Context, userMsg string) { + c.JSON(http.StatusInternalServerError, gin.H{"detail": userMsg}) +} + +// TooManyRequests 429:携带 retry_after_minutes +func TooManyRequests(c *gin.Context, detail string, retryAfterMinutes int) { + c.JSON(http.StatusTooManyRequests, gin.H{"detail": detail, "retry_after_minutes": retryAfterMinutes}) +} diff --git a/backend/internal/handler/scheduled_task.go b/backend/internal/handler/scheduled_task.go index 64be898..d68685e 100644 --- a/backend/internal/handler/scheduled_task.go +++ b/backend/internal/handler/scheduled_task.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "sql-platform/internal/handler/resp" "sql-platform/internal/middleware" "sql-platform/internal/model" @@ -28,7 +29,7 @@ func ListScheduledTasks(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 } var total int64 @@ -40,8 +41,8 @@ func ListScheduledTasks(c *gin.Context) { // Enrich with instance_name and instance_db_type type ScheduledTicketItem struct { model.Ticket - InstanceName string `json:"instance_name"` - InstanceDBType string `json:"instance_db_type"` + InstanceName string `json:"instance_name"` + InstanceDBType string `json:"instance_db_type"` } items := make([]ScheduledTicketItem, 0, len(tickets)) @@ -71,19 +72,19 @@ func CancelScheduledTask(c *gin.Context) { 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 the applicant or admin/dba can cancel if ticket.ApplicantID != userID && role != "admin" && role != "dba" { - c.JSON(http.StatusForbidden, gin.H{"detail": "Permission denied"}) + resp.Forbidden(c, "Permission denied") return } // Only allow cancellation of scheduled tickets if !ticket.ScheduledAt.Valid { - c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket is not scheduled"}) + resp.BadRequest(c, "Ticket is not scheduled") return } diff --git a/backend/internal/handler/stub_handlers.go b/backend/internal/handler/stub_handlers.go index 946d75d..fd4e70c 100644 --- a/backend/internal/handler/stub_handlers.go +++ b/backend/internal/handler/stub_handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -20,7 +21,7 @@ func AuditTicketSQL(c *gin.Context) { Database string `json:"database"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -184,7 +185,7 @@ func SQLFormat(c *gin.Context) { SQL string `json:"sql" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -200,7 +201,7 @@ func SQLAnalyze(c *gin.Context) { DBType string `json:"db_type"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index b3f8949..bf4717b 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -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) } diff --git a/backend/internal/handler/workflow.go b/backend/internal/handler/workflow.go index dbc3610..9ca75d4 100644 --- a/backend/internal/handler/workflow.go +++ b/backend/internal/handler/workflow.go @@ -5,6 +5,7 @@ import ( "strconv" "golang.org/x/crypto/bcrypt" + "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -15,7 +16,7 @@ func GetWorkflow(c *gin.Context) { id := c.Param("id") var wf model.WorkflowDefinition if err := model.DB.First(&wf, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"}) + resp.NotFound(c, "Workflow not found") return } var steps []model.WorkflowStep @@ -55,7 +56,7 @@ func CreateWorkflow(c *gin.Context) { } `json:"steps"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -71,7 +72,7 @@ func CreateWorkflow(c *gin.Context) { IsDefault: input.IsDefault, } if err := model.DB.Create(&wf).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + resp.InternalErr(c, err) return } @@ -99,13 +100,13 @@ func UpdateWorkflow(c *gin.Context) { id := c.Param("id") var wf model.WorkflowDefinition if err := model.DB.First(&wf, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"}) + resp.NotFound(c, "Workflow not found") return } var input map[string]interface{} if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -171,7 +172,7 @@ func ResetPassword(c *gin.Context) { NewPassword string `json:"new_password" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } _, _ = id, input.NewPassword @@ -182,18 +183,18 @@ func ResetPassword(c *gin.Context) { // POST /api/v1/users/batch-import func BatchImportUsers(c *gin.Context) { var input struct { - Users []struct { + Users []struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Role string `json:"role"` Password string `json:"password"` } `json:"users"` - ImportType string `json:"import_type"` - SkipExisting bool `json:"skip_existing"` + ImportType string `json:"import_type"` + SkipExisting bool `json:"skip_existing"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -267,16 +268,16 @@ func BatchImportUsers(c *gin.Context) { func BatchImportLdapUsers(c *gin.Context) { var input struct { Users []struct { - DN string `json:"dn"` - CN string `json:"cn"` - Nickname string `json:"nickname"` - UID string `json:"uid"` - Username string `json:"username"` - Mail string `json:"mail"` + DN string `json:"dn"` + CN string `json:"cn"` + Nickname string `json:"nickname"` + UID string `json:"uid"` + Username string `json:"username"` + Mail string `json:"mail"` } `json:"users"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } @@ -359,21 +360,21 @@ func ResetUserPassword(c *gin.Context) { NewPassword string `json:"new_password" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + resp.BadRequest(c, err.Error()) return } id, err := strconv.Atoi(userId) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid user ID"}) + resp.BadRequest(c, "Invalid user ID") return } hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Hash failed"}) + resp.InternalMsg(c, "Hash failed") return } if err := model.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hash)).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Update failed"}) + resp.InternalMsg(c, "Update failed") return } c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"}) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 3e09f1d..9d2bdaa 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -1,6 +1,9 @@ package middleware import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" "net/http" "strconv" "strings" @@ -13,6 +16,71 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// RefreshTokenTTL refresh token 有效期;access token 有效期仍走 session_timeout_hours +const RefreshTokenTTL = 7 * 24 * time.Hour + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// GenerateRefreshToken 签发 refresh token(明文只返回给客户端,库里只存 SHA-256 摘要) +func GenerateRefreshToken(userID int) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + token := hex.EncodeToString(buf) + rt := model.RefreshToken{ + UserID: userID, + TokenHash: hashToken(token), + ExpiresAt: time.Now().Add(RefreshTokenTTL), + } + if err := model.DB.Create(&rt).Error; err != nil { + return "", err + } + // 顺手清理该用户已过期/已撤销的记录 + model.DB.Where("user_id = ? AND (expires_at < ? OR revoked_at IS NOT NULL)", userID, time.Now()).Delete(&model.RefreshToken{}) + return token, nil +} + +// ConsumeRefreshToken 校验并轮换 refresh token(旧 token 撤销、签发新对),返回所属用户与新 refresh token。 +func ConsumeRefreshToken(tokenStr string) (*model.User, string, error) { + var rt model.RefreshToken + if err := model.DB.Where("token_hash = ?", hashToken(tokenStr)).First(&rt).Error; err != nil { + return nil, "", err + } + if rt.RevokedAt != nil || rt.ExpiresAt.Before(time.Now()) { + return nil, "", ErrTokenRevoked + } + + var user model.User + if err := model.DB.First(&user, rt.UserID).Error; err != nil { + return nil, "", err + } + if user.IsActive != nil && !*user.IsActive { + return nil, "", ErrTokenRevoked + } + + now := time.Now() + rt.RevokedAt = &now + model.DB.Save(&rt) + + newToken, err := GenerateRefreshToken(rt.UserID) + if err != nil { + return nil, "", err + } + return &user, newToken, nil +} + +// RevokeUserRefreshTokens 撤销用户全部 refresh token(登出/改密时调用) +func RevokeUserRefreshTokens(userID int) { + now := time.Now() + model.DB.Model(&model.RefreshToken{}). + Where("user_id = ? AND revoked_at IS NULL", userID). + Update("revoked_at", now) +} + // getSessionTimeoutHours reads session_timeout_hours from system_settings, // falls back to config/env, then default 24h. func getSessionTimeoutHours() int { @@ -35,6 +103,13 @@ type Claims struct { jwt.RegisteredClaims } +// ErrTokenRevoked refresh token 已撤销/过期 +var ErrTokenRevoked = errTokenRevoked{} + +type errTokenRevoked struct{} + +func (errTokenRevoked) Error() string { return "refresh token revoked or expired" } + func GenerateToken(user *model.User) (string, error) { claims := Claims{ UserID: user.ID, diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 6ae0e01..4aa78b2 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -2,6 +2,7 @@ package model import ( "database/sql" + "encoding/json" "time" "gorm.io/gorm" @@ -10,21 +11,21 @@ import ( // ==================== User ==================== type User struct { - ID int `gorm:"primaryKey" json:"id"` - Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` - DisplayName string `gorm:"size:100;not null" json:"display_name"` - Email *string `gorm:"size:100" json:"email"` - Phone *string `gorm:"size:20" json:"phone"` - HashedPassword string `gorm:"size:200;not null" json:"-"` - AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap - Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor - IsActive *bool `gorm:"default:true" json:"is_active"` - Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"system","login_style":"split"} - LastLogin *time.Time `json:"last_login"` - FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"` - LockedUntil *time.Time `json:"locked_until"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + ID int `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` + DisplayName string `gorm:"size:100;not null" json:"display_name"` + Email *string `gorm:"size:100" json:"email"` + Phone *string `gorm:"size:20" json:"phone"` + HashedPassword string `gorm:"size:200;not null" json:"-"` + AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap + Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor + IsActive *bool `gorm:"default:true" json:"is_active"` + Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"system","login_style":"split"} + LastLogin *time.Time `json:"last_login"` + FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"` + LockedUntil *time.Time `json:"locked_until"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` } func (User) TableName() string { return "users" } @@ -32,57 +33,66 @@ func (User) TableName() string { return "users" } // ==================== Instance ==================== type Instance struct { - ID int `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null" json:"name"` - DBType string `gorm:"size:20;not null" json:"db_type"` - Host string `gorm:"size:200;not null" json:"host"` - Port int `gorm:"not null" json:"port"` - Username string `gorm:"size:100;not null" json:"username"` - Password string `gorm:"size:500;not null" json:"password,omitempty"` - Database *string `gorm:"size:100" json:"database"` - Env *string `gorm:"size:20" json:"env"` - Description *string `gorm:"type:text" json:"description"` - Status *string `gorm:"size:20" json:"status"` - Version *string `gorm:"size:50" json:"version"` - ClusterMode *bool `gorm:"default:false" json:"cluster_mode"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` - AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"` - Workflow *string `gorm:"type:text" json:"workflow"` - ExcludedDatabases *string `gorm:"type:text" json:"excluded_databases"` - MaskFields *string `gorm:"type:text" json:"mask_fields"` - GroupID *int `json:"group_id"` - WorkflowID *int `json:"workflow_id"` + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + DBType string `gorm:"size:20;not null" json:"db_type"` + Host string `gorm:"size:200;not null" json:"host"` + Port int `gorm:"not null" json:"port"` + Username string `gorm:"size:100;not null" json:"username"` + Password string `gorm:"size:500;not null" json:"password,omitempty"` + Database *string `gorm:"size:100" json:"database"` + Env *string `gorm:"size:20" json:"env"` + Description *string `gorm:"type:text" json:"description"` + Status *string `gorm:"size:20" json:"status"` + Version *string `gorm:"size:50" json:"version"` + ClusterMode *bool `gorm:"default:false" json:"cluster_mode"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"` + Workflow *string `gorm:"type:text" json:"workflow"` + ExcludedDatabases *string `gorm:"type:text" json:"excluded_databases"` + MaskFields *string `gorm:"type:text" json:"mask_fields"` + GroupID *int `json:"group_id"` + WorkflowID *int `json:"workflow_id"` } func (Instance) TableName() string { return "instances" } +// MarshalJSON 保证实例密文密码在任何响应中都不出站, +// 消除各 handler 手动遮蔽遗漏的隐患(入站绑定不受影响)。 +func (i Instance) MarshalJSON() ([]byte, error) { + type instanceAlias Instance + a := instanceAlias(i) + a.Password = "" + return json.Marshal(a) +} + // ==================== Ticket ==================== type Ticket struct { - ID int `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:200;not null" json:"title"` - DBType string `gorm:"size:20;not null" json:"db_type"` - InstanceID int `gorm:"not null" json:"instance_id"` - TargetDatabase string `gorm:"size:100;not null" json:"target_database"` - SQLContent string `gorm:"type:text;not null" json:"sql_content"` - RollbackSQL *string `gorm:"type:text" json:"rollback_sql"` - Status *string `gorm:"size:20" json:"status"` - Priority *string `gorm:"size:10" json:"priority"` - ApplicantID int `gorm:"not null" json:"applicant_id"` - ApproverID *int `json:"approver_id"` - Reason *string `gorm:"type:text" json:"reason"` - AuditResult *string `gorm:"type:text" json:"audit_result"` - ExecuteResult *string `gorm:"type:text" json:"execute_result"` - CreateTime time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"create_time"` - ReviewTime *time.Time `json:"review_time"` - ExecuteTime *time.Time `json:"execute_time"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` - TicketNo *string `gorm:"size:20" json:"ticket_no"` - ScheduledAt sql.NullTime `json:"scheduled_at"` - WorkflowID *int `json:"workflow_id"` - RiskLevel *string `gorm:"size:20;default:low" json:"risk_level"` - ExecStrategy *string `gorm:"size:20;default:stop_on_error" json:"exec_strategy"` + ID int `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:200;not null" json:"title"` + DBType string `gorm:"size:20;not null" json:"db_type"` + InstanceID int `gorm:"not null" json:"instance_id"` + TargetDatabase string `gorm:"size:100;not null" json:"target_database"` + SQLContent string `gorm:"type:text;not null" json:"sql_content"` + RollbackSQL *string `gorm:"type:text" json:"rollback_sql"` + Status *string `gorm:"size:20" json:"status"` + Priority *string `gorm:"size:10" json:"priority"` + ApplicantID int `gorm:"not null" json:"applicant_id"` + ApproverID *int `json:"approver_id"` + Reason *string `gorm:"type:text" json:"reason"` + AuditResult *string `gorm:"type:text" json:"audit_result"` + ExecuteResult *string `gorm:"type:text" json:"execute_result"` + CreateTime time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"create_time"` + ReviewTime *time.Time `json:"review_time"` + ExecuteTime *time.Time `json:"execute_time"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + TicketNo *string `gorm:"size:20" json:"ticket_no"` + ScheduledAt sql.NullTime `json:"scheduled_at"` + WorkflowID *int `json:"workflow_id"` + RiskLevel *string `gorm:"size:20;default:low" json:"risk_level"` + ExecStrategy *string `gorm:"size:20;default:stop_on_error" json:"exec_strategy"` } func (Ticket) TableName() string { return "tickets" } @@ -90,24 +100,24 @@ func (Ticket) TableName() string { return "tickets" } // ==================== TicketApproval ==================== type TicketApproval struct { - ID int `gorm:"primaryKey" json:"id"` - TicketID int `gorm:"not null" json:"ticket_id"` - WorkflowID *int `json:"workflow_id"` - VersionID *int `json:"version_id"` - StepNumber int `gorm:"not null" json:"step_number"` - StepName *string `gorm:"size:100" json:"step_name"` - StepType *string `gorm:"size:20" json:"step_type"` - ApproverID *int `json:"approver_id"` - ApproverRole *string `gorm:"size:20" json:"approver_role"` - ActualApproverID *int `json:"actual_approver_id"` - Action *string `gorm:"size:20" json:"action"` - Comment *string `gorm:"type:text" json:"comment"` - TransferFrom *int `json:"transfer_from"` - TransferReason *string `gorm:"type:text" json:"transfer_reason"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` - CompletedAt *time.Time `json:"completed_at"` - IsTimeout *bool `json:"is_timeout"` + ID int `gorm:"primaryKey" json:"id"` + TicketID int `gorm:"not null" json:"ticket_id"` + WorkflowID *int `json:"workflow_id"` + VersionID *int `json:"version_id"` + StepNumber int `gorm:"not null" json:"step_number"` + StepName *string `gorm:"size:100" json:"step_name"` + StepType *string `gorm:"size:20" json:"step_type"` + ApproverID *int `json:"approver_id"` + ApproverRole *string `gorm:"size:20" json:"approver_role"` + ActualApproverID *int `json:"actual_approver_id"` + Action *string `gorm:"size:20" json:"action"` + Comment *string `gorm:"type:text" json:"comment"` + TransferFrom *int `json:"transfer_from"` + TransferReason *string `gorm:"type:text" json:"transfer_reason"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + CompletedAt *time.Time `json:"completed_at"` + IsTimeout *bool `json:"is_timeout"` TimeoutHandledAt *time.Time `json:"timeout_handled_at"` } @@ -116,18 +126,18 @@ func (TicketApproval) TableName() string { return "ticket_approvals" } // ==================== TicketSQLItem ==================== type TicketSQLItem struct { - ID int `gorm:"primaryKey" json:"id"` - TicketID int `gorm:"not null" json:"ticket_id"` - Seq int `gorm:"not null" json:"seq"` - SQLText string `gorm:"type:text;not null" json:"sql_text"` - SQLType *string `gorm:"size:20" json:"sql_type"` - Status *string `gorm:"size:20;default:pending" json:"status"` - AffectedRows *int `gorm:"default:0" json:"affected_rows"` - DurationMs *int `gorm:"default:0" json:"duration_ms"` - ErrorMessage *string `gorm:"type:text" json:"error_message"` - StartedAt *time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + ID int `gorm:"primaryKey" json:"id"` + TicketID int `gorm:"not null" json:"ticket_id"` + Seq int `gorm:"not null" json:"seq"` + SQLText string `gorm:"type:text;not null" json:"sql_text"` + SQLType *string `gorm:"size:20" json:"sql_type"` + Status *string `gorm:"size:20;default:pending" json:"status"` + AffectedRows *int `gorm:"default:0" json:"affected_rows"` + DurationMs *int `gorm:"default:0" json:"duration_ms"` + ErrorMessage *string `gorm:"type:text" json:"error_message"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` } func (TicketSQLItem) TableName() string { return "ticket_sql_items" } @@ -204,37 +214,37 @@ func (QueryHistory) TableName() string { return "query_histories" } // ==================== Workflow ==================== type WorkflowDefinition struct { - ID int `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null" json:"name"` - Description *string `gorm:"type:text" json:"description"` - Category *string `gorm:"size:50" json:"category"` - IsDefault *bool `json:"is_default"` - IsActive *bool `json:"is_active"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` - CreatedBy *int `json:"created_by"` - FlowConfig *string `gorm:"type:json" json:"flow_config"` - TriggerConditions *string `gorm:"type:json" json:"trigger_conditions"` + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + Category *string `gorm:"size:50" json:"category"` + IsDefault *bool `json:"is_default"` + IsActive *bool `json:"is_active"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + CreatedBy *int `json:"created_by"` + FlowConfig *string `gorm:"type:json" json:"flow_config"` + TriggerConditions *string `gorm:"type:json" json:"trigger_conditions"` } func (WorkflowDefinition) TableName() string { return "workflow_definitions" } type WorkflowStep struct { - ID int `gorm:"primaryKey" json:"id"` - WorkflowID int `gorm:"not null" json:"workflow_id"` - StepNumber int `gorm:"not null" json:"step_number"` - Name string `gorm:"size:100;not null" json:"name"` - Description *string `gorm:"type:text" json:"description"` - StepType string `gorm:"size:20;not null" json:"step_type"` - ApproverRole *string `gorm:"size:20" json:"approver_role"` - ApproverIDs *string `gorm:"type:json" json:"approver_ids"` - ApproverAssignment *string `gorm:"type:json" json:"approver_assignment"` - IsRequired *bool `json:"is_required"` - TimeoutHours *int `json:"timeout_hours"` - AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"` - EntryConditions *string `gorm:"type:json" json:"entry_conditions"` - TransitionConditions *string `gorm:"type:json" json:"transition_conditions"` - FieldPermissions *string `gorm:"type:json" json:"field_permissions"` + ID int `gorm:"primaryKey" json:"id"` + WorkflowID int `gorm:"not null" json:"workflow_id"` + StepNumber int `gorm:"not null" json:"step_number"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + StepType string `gorm:"size:20;not null" json:"step_type"` + ApproverRole *string `gorm:"size:20" json:"approver_role"` + ApproverIDs *string `gorm:"type:json" json:"approver_ids"` + ApproverAssignment *string `gorm:"type:json" json:"approver_assignment"` + IsRequired *bool `json:"is_required"` + TimeoutHours *int `json:"timeout_hours"` + AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"` + EntryConditions *string `gorm:"type:json" json:"entry_conditions"` + TransitionConditions *string `gorm:"type:json" json:"transition_conditions"` + FieldPermissions *string `gorm:"type:json" json:"field_permissions"` } func (WorkflowStep) TableName() string { return "workflow_steps" } @@ -359,15 +369,15 @@ func (NotificationTemplate) TableName() string { return "notification_templates" // ==================== Other Models ==================== type SystemSetting struct { - ID int `gorm:"primaryKey" json:"id"` - Key string `gorm:"size:100;not null" json:"key"` - Value string `gorm:"type:text;not null" json:"value"` - ValueType string `gorm:"size:20;not null" json:"value_type"` - Group string `gorm:"size:50;not null" json:"group"` - Label string `gorm:"size:100;not null" json:"label"` - Desc *string `gorm:"type:text" json:"description"` - DefaultValue *string `gorm:"type:text" json:"default_value"` - SortOrder int `gorm:"not null" json:"sort_order"` + ID int `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:100;not null" json:"key"` + Value string `gorm:"type:text;not null" json:"value"` + ValueType string `gorm:"size:20;not null" json:"value_type"` + Group string `gorm:"size:50;not null" json:"group"` + Label string `gorm:"size:100;not null" json:"label"` + Desc *string `gorm:"type:text" json:"description"` + DefaultValue *string `gorm:"type:text" json:"default_value"` + SortOrder int `gorm:"not null" json:"sort_order"` UpdatedAt *time.Time `json:"updated_at"` } @@ -420,15 +430,15 @@ type Backup struct { func (Backup) TableName() string { return "backups" } type SchemaRevision struct { - ID int `gorm:"primaryKey" json:"id"` - InstanceID int `gorm:"not null" json:"instance_id"` - Database string `gorm:"size:100;not null" json:"database"` - Revision string `gorm:"size:64;not null" json:"revision"` - DDLContent *string `gorm:"type:text" json:"ddl_content"` - Checksum string `gorm:"size:64;not null" json:"checksum"` - AppliedBy *int `json:"applied_by"` - AppliedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"applied_at"` - Status *string `gorm:"size:20" json:"status"` + ID int `gorm:"primaryKey" json:"id"` + InstanceID int `gorm:"not null" json:"instance_id"` + Database string `gorm:"size:100;not null" json:"database"` + Revision string `gorm:"size:64;not null" json:"revision"` + DDLContent *string `gorm:"type:text" json:"ddl_content"` + Checksum string `gorm:"size:64;not null" json:"checksum"` + AppliedBy *int `json:"applied_by"` + AppliedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"applied_at"` + Status *string `gorm:"size:20" json:"status"` } func (SchemaRevision) TableName() string { return "schema_revisions" } @@ -450,50 +460,64 @@ type SlowLog struct { func (SlowLog) TableName() string { return "slow_logs" } type ScheduledTask struct { - ID int `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null" json:"name"` - Description *string `gorm:"type:text" json:"description"` - TaskSQLType string `gorm:"size:20;not null" json:"task_sql_type"` - InstanceID int `gorm:"not null" json:"instance_id"` - TargetDB *string `gorm:"size:100" json:"target_db"` - TargetTable *string `gorm:"size:100" json:"target_table"` - SQLContent string `gorm:"type:text;not null" json:"sql_content"` - CronExpression string `gorm:"size:50;not null" json:"cron_expression"` - IsEnabled *bool `json:"is_enabled"` - LastRunAt *time.Time `json:"last_run_at"` - LastRunStatus *string `gorm:"size:20" json:"last_run_status"` - LastRunMessage *string `gorm:"type:text" json:"last_run_message"` - NextRunAt *time.Time `json:"next_run_at"` - RunCount *int `json:"run_count"` - CreatedBy *int `json:"created_by"` - CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + TaskSQLType string `gorm:"size:20;not null" json:"task_sql_type"` + InstanceID int `gorm:"not null" json:"instance_id"` + TargetDB *string `gorm:"size:100" json:"target_db"` + TargetTable *string `gorm:"size:100" json:"target_table"` + SQLContent string `gorm:"type:text;not null" json:"sql_content"` + CronExpression string `gorm:"size:50;not null" json:"cron_expression"` + IsEnabled *bool `json:"is_enabled"` + LastRunAt *time.Time `json:"last_run_at"` + LastRunStatus *string `gorm:"size:20" json:"last_run_status"` + LastRunMessage *string `gorm:"type:text" json:"last_run_message"` + NextRunAt *time.Time `json:"next_run_at"` + RunCount *int `json:"run_count"` + CreatedBy *int `json:"created_by"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` } func (ScheduledTask) TableName() string { return "scheduled_tasks" } type ScheduledTaskLog struct { - ID int `gorm:"primaryKey" json:"id"` - TaskID int `gorm:"not null" json:"task_id"` - Status string `gorm:"size:20;not null" json:"status"` - SQLContent *string `gorm:"type:text" json:"sql_content"` - AffectedRows *int `json:"affected_rows"` - DurationMs *int `json:"duration_ms"` - ErrorMessage *string `gorm:"type:text" json:"error_message"` - StartedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"started_at"` - FinishedAt *time.Time `json:"finished_at"` + ID int `gorm:"primaryKey" json:"id"` + TaskID int `gorm:"not null" json:"task_id"` + Status string `gorm:"size:20;not null" json:"status"` + SQLContent *string `gorm:"type:text" json:"sql_content"` + AffectedRows *int `json:"affected_rows"` + DurationMs *int `json:"duration_ms"` + ErrorMessage *string `gorm:"type:text" json:"error_message"` + StartedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` } func (ScheduledTaskLog) TableName() string { return "scheduled_task_logs" } type UserGroup struct { - UserID int `gorm:"primaryKey" json:"user_id"` - GroupID int `gorm:"primaryKey" json:"group_id"` + UserID int `gorm:"primaryKey" json:"user_id"` + GroupID int `gorm:"primaryKey" json:"group_id"` Permissions *string `gorm:"type:text" json:"permissions"` } func (UserGroup) TableName() string { return "user_groups" } +// ==================== RefreshToken ==================== + +// RefreshToken 持久化 refresh token 的 SHA-256 摘要(明文不落库),支持过期与撤销。 +type RefreshToken struct { + ID int `gorm:"primaryKey" json:"id"` + UserID int `gorm:"index;not null" json:"user_id"` + TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (RefreshToken) TableName() string { return "refresh_tokens" } + // ==================== DTO ==================== type LoginRequest struct { @@ -502,9 +526,10 @@ type LoginRequest struct { } type LoginResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - User User `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + User User `json:"user"` } type TicketStatistics struct { diff --git a/backend/internal/safe/safe.go b/backend/internal/safe/safe.go new file mode 100644 index 0000000..7af7b72 --- /dev/null +++ b/backend/internal/safe/safe.go @@ -0,0 +1,20 @@ +// Package safe 提供带 recover 的后台 goroutine 包装: +// 裸 go func() 一旦 panic 会击穿整个进程,后台任务必须经由 safe.Go 启动。 +package safe + +import ( + "log" + "runtime/debug" +) + +// Go 启动带 recover 的后台任务;panic 只记录堆栈,不影响进程。 +func Go(name string, fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[safe:%s] panic recovered: %v\n%s", name, r, debug.Stack()) + } + }() + fn() + }() +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go deleted file mode 100644 index 24323df..0000000 --- a/backend/internal/service/auth_service.go +++ /dev/null @@ -1,153 +0,0 @@ -package service - -import ( - "errors" - "time" - - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" - "sql-platform/internal/middleware" - "sql-platform/internal/model" -) - -func stringPtr(value string) *string { - return &value -} - -func boolPtr(value bool) *bool { - return &value -} - -type AuthService struct { - DB *gorm.DB -} - -func NewAuthService(db *gorm.DB) *AuthService { - return &AuthService{DB: db} -} - -// Login authenticates a user and returns a JWT token -func (s *AuthService) Login(username, password string) (*model.User, string, error) { - var user model.User - if err := s.DB.Where("username = ?", username).First(&user).Error; err != nil { - return nil, "", errors.New("invalid username or password") - } - - if user.IsActive != nil && !*user.IsActive { - return nil, "", errors.New("account is disabled") - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password)); err != nil { - return nil, "", errors.New("invalid username or password") - } - - token, err := middleware.GenerateToken(&user) - if err != nil { - return nil, "", errors.New("failed to generate token") - } - - // Update last login - now := time.Now() - user.LastLogin = &now - s.DB.Model(&user).Update("last_login", now) - - return &user, token, nil -} - -// Register creates a new user -func (s *AuthService) Register(username, displayName, email, phone, password, role string) (*model.User, error) { - // Check if username already exists - var count int64 - s.DB.Model(&model.User{}).Where("username = ?", username).Count(&count) - if count > 0 { - return nil, errors.New("username already exists") - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return nil, errors.New("failed to hash password") - } - - if role == "" { - role = "developer" - } - - user := model.User{ - Username: username, - DisplayName: displayName, - Email: stringPtr(email), - Phone: stringPtr(phone), - HashedPassword: string(hashedPassword), - Role: role, - IsActive: boolPtr(true), - } - - if err := s.DB.Create(&user).Error; err != nil { - return nil, errors.New("failed to create user") - } - - return &user, nil -} - -// GetUserByID retrieves a user by ID -func (s *AuthService) GetUserByID(id uint) (*model.User, error) { - var user model.User - if err := s.DB.First(&user, id).Error; err != nil { - return nil, errors.New("user not found") - } - return &user, nil -} - -// ListUsers returns all users -func (s *AuthService) ListUsers(page, pageSize int) ([]model.User, int64, error) { - var users []model.User - var total int64 - - db := s.DB.Model(&model.User{}) - db.Count(&total) - - offset := (page - 1) * pageSize - if err := db.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { - return nil, 0, err - } - - return users, total, nil -} - -// UpdateUser updates a user's info -func (s *AuthService) UpdateUser(id uint, updates map[string]interface{}) error { - return s.DB.Model(&model.User{}).Where("id = ?", id).Updates(updates).Error -} - -// DeleteUser soft-deletes a user -func (s *AuthService) DeleteUser(id uint) error { - return s.DB.Delete(&model.User{}, id).Error -} - -// ChangePassword changes a user's password -func (s *AuthService) ChangePassword(id uint, oldPassword, newPassword string) error { - var user model.User - if err := s.DB.First(&user, id).Error; err != nil { - return errors.New("user not found") - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(oldPassword)); err != nil { - return errors.New("incorrect current password") - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return errors.New("failed to hash password") - } - - return s.DB.Model(&user).Update("hashed_password", string(hashedPassword)).Error -} - -// ResetPassword resets a user's password (admin only) -func (s *AuthService) ResetPassword(id uint, newPassword string) error { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return errors.New("failed to hash password") - } - return s.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hashedPassword)).Error -} diff --git a/backend/internal/service/instance_service.go b/backend/internal/service/instance_service.go deleted file mode 100644 index 18ac4a1..0000000 --- a/backend/internal/service/instance_service.go +++ /dev/null @@ -1,93 +0,0 @@ -package service - -import ( - "errors" - - "gorm.io/gorm" - "sql-platform/internal/model" -) - -type InstanceService struct { - DB *gorm.DB -} - -func NewInstanceService(db *gorm.DB) *InstanceService { - return &InstanceService{DB: db} -} - -// CreateInstance creates a new database instance -func (s *InstanceService) CreateInstance(instance *model.Instance) error { - return s.DB.Create(instance).Error -} - -// GetInstanceByID retrieves an instance by ID -func (s *InstanceService) GetInstanceByID(id uint) (*model.Instance, error) { - var instance model.Instance - if err := s.DB.First(&instance, id).Error; err != nil { - return nil, errors.New("instance not found") - } - return &instance, nil -} - -// ListInstances returns paginated instances -func (s *InstanceService) ListInstances(page, pageSize int, dbType, env string) ([]model.Instance, int64, error) { - var instances []model.Instance - var total int64 - - db := s.DB.Model(&model.Instance{}) - if dbType != "" { - db = db.Where("db_type = ?", dbType) - } - if env != "" { - db = db.Where("env = ?", env) - } - - db.Count(&total) - offset := (page - 1) * pageSize - if err := db.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&instances).Error; err != nil { - return nil, 0, err - } - - return instances, total, nil -} - -// UpdateInstance updates an instance -func (s *InstanceService) UpdateInstance(id uint, updates map[string]interface{}) error { - result := s.DB.Model(&model.Instance{}).Where("id = ?", id).Updates(updates) - if result.RowsAffected == 0 { - return errors.New("instance not found") - } - return result.Error -} - -// DeleteInstance soft-deletes an instance -func (s *InstanceService) DeleteInstance(id uint) error { - return s.DB.Delete(&model.Instance{}, id).Error -} - -// TestConnection tests the database connection -func (s *InstanceService) TestConnection(instance *model.Instance) error { - // TODO: Implement actual connection test based on db_type - return nil -} - -// ListInstancesByIDs returns instances matching the given IDs -func (s *InstanceService) ListInstancesByIDs(ids []uint) ([]model.Instance, error) { - var instances []model.Instance - if err := s.DB.Where("id IN ?", ids).Find(&instances).Error; err != nil { - return nil, err - } - return instances, nil -} - -// GetInstanceGroups returns all instance groups -func (s *InstanceService) GetInstanceGroups() ([]model.InstanceGroup, error) { - var groups []model.InstanceGroup - err := s.DB.Find(&groups).Error - return groups, err -} - -// CreateInstanceGroup creates a new instance group -func (s *InstanceService) CreateInstanceGroup(group *model.InstanceGroup) error { - return s.DB.Create(group).Error -} diff --git a/backend/internal/service/ticket_service.go b/backend/internal/service/ticket_service.go deleted file mode 100644 index 916e841..0000000 --- a/backend/internal/service/ticket_service.go +++ /dev/null @@ -1,131 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "time" - - "gorm.io/gorm" - "sql-platform/internal/model" -) - -type TicketService struct { - DB *gorm.DB -} - -func NewTicketService(db *gorm.DB) *TicketService { - return &TicketService{DB: db} -} - -// CreateTicket creates a new ticket -func (s *TicketService) CreateTicket(ticket *model.Ticket) error { - if ticket.TicketNo == nil || *ticket.TicketNo == "" { - ticketNo := s.generateTicketNo() - ticket.TicketNo = &ticketNo - } - if ticket.CreateTime.IsZero() { - ticket.CreateTime = time.Now() - } - if ticket.Status == nil || *ticket.Status == "" { - status := "pending" - ticket.Status = &status - } - return s.DB.Create(ticket).Error -} - -// GetTicketByID retrieves a ticket by ID -func (s *TicketService) GetTicketByID(id uint) (*model.Ticket, error) { - var ticket model.Ticket - if err := s.DB.First(&ticket, id).Error; err != nil { - return nil, errors.New("ticket not found") - } - return &ticket, nil -} - -// ListTickets returns paginated tickets with filters -func (s *TicketService) ListTickets(page, pageSize int, status, priority string, applicantID uint) ([]model.Ticket, int64, error) { - var tickets []model.Ticket - var total int64 - - db := s.DB.Model(&model.Ticket{}) - if status != "" { - db = db.Where("status = ?", status) - } - if priority != "" { - db = db.Where("priority = ?", priority) - } - if applicantID > 0 { - db = db.Where("applicant_id = ?", applicantID) - } - - db.Count(&total) - offset := (page - 1) * pageSize - if err := db.Offset(offset).Limit(pageSize).Order("create_time DESC").Find(&tickets).Error; err != nil { - return nil, 0, err - } - - return tickets, total, nil -} - -// UpdateTicketStatus updates a ticket's status -func (s *TicketService) UpdateTicketStatus(id uint, status string, updaterID uint) error { - updates := map[string]interface{}{ - "status": status, - } - now := time.Now() - switch status { - case "approved": - updates["approver_id"] = updaterID - updates["review_time"] = now - case "executing", "completed", "failed": - updates["execute_time"] = now - } - - result := s.DB.Model(&model.Ticket{}).Where("id = ?", id).Updates(updates) - if result.RowsAffected == 0 { - return errors.New("ticket not found") - } - return result.Error -} - -// ApproveTicket approves a ticket -func (s *TicketService) ApproveTicket(id, approverID uint, comment string) error { - return s.UpdateTicketStatus(id, "approved", approverID) -} - -// RejectTicket rejects a ticket -func (s *TicketService) RejectTicket(id, approverID uint, comment string) error { - return s.UpdateTicketStatus(id, "rejected", approverID) -} - -// GetTicketSQLItems returns SQL items for a ticket -func (s *TicketService) GetTicketSQLItems(ticketID uint) ([]model.TicketSQLItem, error) { - var items []model.TicketSQLItem - err := s.DB.Where("ticket_id = ?", ticketID).Order("seq ASC").Find(&items).Error - return items, err -} - -// CreateTicketSQLItem creates a SQL item for a ticket -func (s *TicketService) CreateTicketSQLItem(item *model.TicketSQLItem) error { - return s.DB.Create(item).Error -} - -// GetTicketApprovals returns approval records for a ticket -func (s *TicketService) GetTicketApprovals(ticketID uint) ([]model.TicketApproval, error) { - var approvals []model.TicketApproval - err := s.DB.Where("ticket_id = ?", ticketID).Order("step_number ASC").Find(&approvals).Error - return approvals, err -} - -// DeleteTicket soft-deletes a ticket -func (s *TicketService) DeleteTicket(id uint) error { - return s.DB.Delete(&model.Ticket{}, id).Error -} - -// generateTicketNo generates a unique ticket number -func (s *TicketService) generateTicketNo() string { - now := time.Now() - var count int64 - s.DB.Model(&model.Ticket{}).Where("DATE(create_time) = DATE(?)", now).Count(&count) - return fmt.Sprintf("TK-%s-%04d", now.Format("20060102"), count+1) -} diff --git a/backend/router/router.go b/backend/router/router.go index 28fbe87..1dfb73b 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -12,6 +12,7 @@ func Setup(r *gin.Engine) { // Public routes api.POST("/auth/login", handler.Login) + api.POST("/auth/refresh", handler.RefreshToken) api.GET("/public/login-style", handler.GetPublicLoginStyle) // Authenticated routes @@ -20,6 +21,7 @@ func Setup(r *gin.Engine) { { // Auth auth.GET("/auth/me", handler.GetMe) + auth.POST("/auth/logout", handler.Logout) // Dashboard auth.GET("/dashboard/stats", handler.DashboardStats)