feat(backend): unified response helpers, refresh token, security hardening

- new resp package (OK/Page/Fail/BadRequest/.../InternalErr): unified error
  envelope {detail, code?}, sanitized 500s (full error logged server-side,
  generic message to client - stops DB error leakage), pagination envelope
  {items,total,page,page_size}; migrate 200+ inline c.JSON error sites
- refresh token: 7d rotation tokens (SHA-256 hashed at rest), public
  POST /auth/refresh with single-use rotation, POST /auth/logout revokes
  all user refresh tokens (endpoint previously missing - frontend 404)
- security: gh-ost command line password masked in task status payload;
  Instance.MarshalJSON guarantees encrypted passwords never leave the API
  (removes reliance on per-handler manual masking); production (DEBUG=false)
  with default JWT secret now auto-generates and persists a random secret
- safe.Go recover wrapper for background goroutines (instance probe,
  gh-ost async)
- delete dead service layer (zero callers)
- fix pre-existing go vet unkeyed bson.E literals
This commit is contained in:
Logan.Li committed 2026-10-26 17:21:00 +08:00
1 parent d5b8b8116c
commit 16f1f7d65f
25 files changed
+565 -612

No files matched your search

+4
View File
@@ -58,11 +58,15 @@ func main() {
&model.ScheduledTask{}, &model.ScheduledTask{},
&model.ScheduledTaskLog{}, &model.ScheduledTaskLog{},
&model.UserGroup{}, &model.UserGroup{},
&model.RefreshToken{},
) )
// Seed default data (idempotent): users, workflow, settings // Seed default data (idempotent): users, workflow, settings
seedAll(db) seedAll(db)
// 治理默认 JWT 密钥(生产模式自动生成并持久化)
config.EnsureJWTSecret()
// Setup Gin // Setup Gin
r := gin.Default() r := gin.Default()
r.Use(middleware.CORSMiddleware()) r.Use(middleware.CORSMiddleware())
+45 -1
View File
@@ -1,12 +1,20 @@
package config package config
import ( import (
"crypto/rand"
"encoding/hex"
"log"
"os" "os"
"strconv" "strconv"
"sql-platform/internal/model"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
// DefaultJWTSecret 仅用于本地 DEBUG;生产模式(DEBUG=false)会被 EnsureJWTSecret 替换为随机密钥
const DefaultJWTSecret = "sql-platform-secret-key-change-in-production"
type Config struct { type Config struct {
Port string Port string
DBPath string DBPath string
@@ -24,13 +32,49 @@ func Init() {
C = Config{ C = Config{
Port: getEnv("PORT", "9000"), Port: getEnv("PORT", "9000"),
DBPath: getEnv("DB_PATH", "../backend/sqlite3.db"), 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), JWTExpire: getEnvInt("JWT_EXPIRE_HOURS", 24),
GhOstPath: getEnv("GH_OST_PATH", "gh-ost"), GhOstPath: getEnv("GH_OST_PATH", "gh-ost"),
Debug: getEnvBool("DEBUG", true), 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 { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
+13 -4
View File
@@ -5,9 +5,12 @@ import (
"context" "context"
"fmt" "fmt"
"os/exec" "os/exec"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"sql-platform/internal/safe"
) )
// GhOstTask defines a gh-ost execution task // GhOstTask defines a gh-ost execution task
@@ -121,7 +124,7 @@ func RunGhOst(ctx context.Context, task GhOstTask, ghOstPath string) (*GhOstResu
err := cmd.Run() err := cmd.Run()
result := &GhOstResult{ result := &GhOstResult{
Command: "gh-ost " + strings.Join(args, " "), Command: "gh-ost " + maskSecrets(strings.Join(args, " ")),
Duration: time.Since(start).String(), Duration: time.Since(start).String(),
Output: stdout.String(), Output: stdout.String(),
} }
@@ -145,7 +148,7 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string
Manager.tasks[taskID] = &GhOstResult{TaskID: taskID} Manager.tasks[taskID] = &GhOstResult{TaskID: taskID}
Manager.mu.Unlock() Manager.mu.Unlock()
go func() { run := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel() defer cancel()
@@ -165,7 +168,7 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string
result := &GhOstResult{ result := &GhOstResult{
TaskID: taskID, TaskID: taskID,
Command: "gh-ost " + strings.Join(args, " "), Command: "gh-ost " + maskSecrets(strings.Join(args, " ")),
Duration: time.Since(start).String(), Duration: time.Since(start).String(),
Output: stdout.String(), Output: stdout.String(),
Success: err == nil, Success: err == nil,
@@ -183,11 +186,17 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string
if cb != nil { if cb != nil {
cb(taskID, result.Output) cb(taskID, result.Output)
} }
}() }
safe.Go("ghost:"+taskID, run)
return taskID return taskID
} }
// maskSecrets 将命令行中的密码参数值替换为 ******,防止经任务状态接口回传前端
func maskSecrets(cmdline string) string {
return regexp.MustCompile(`(--password=)([^ ]+)`).ReplaceAllString(cmdline, `$1******`)
}
// GetTaskStatus returns the status of an async task // GetTaskStatus returns the status of an async task
func (m *TaskManager) GetTaskStatus(taskID string) (string, *GhOstResult) { func (m *TaskManager) GetTaskStatus(taskID string) (string, *GhOstResult) {
m.mu.RLock() m.mu.RLock()
+59 -18
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
@@ -22,7 +23,7 @@ import (
func Login(c *gin.Context) { func Login(c *gin.Context) {
var req model.LoginRequest var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil { 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 return
} }
@@ -34,7 +35,7 @@ func Login(c *gin.Context) {
maxAttempts := getSettingInt("login_max_attempts", 5) maxAttempts := getSettingInt("login_max_attempts", 5)
lockMin := getSettingInt("login_lockout_minutes", 30) lockMin := getSettingInt("login_lockout_minutes", 30)
remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1 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 return
} }
// Lock expired → reset // Lock expired → reset
@@ -47,7 +48,7 @@ func Login(c *gin.Context) {
if err == nil { if err == nil {
// ── 本地用户存在 ── // ── 本地用户存在 ──
if user.IsActive != nil && !*user.IsActive { if user.IsActive != nil && !*user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"}) resp.Forbidden(c, "Account is disabled")
return return
} }
@@ -60,7 +61,7 @@ func Login(c *gin.Context) {
if ldapErr != nil || ldapInfo == nil { if ldapErr != nil || ldapInfo == nil {
// Both local and LDAP failed → record failed attempt // Both local and LDAP failed → record failed attempt
handleFailedLogin(&user) handleFailedLogin(&user)
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) resp.Unauthorized(c, "Invalid username or password")
return return
} }
// LDAP OK — update local user info // LDAP OK — update local user info
@@ -81,7 +82,7 @@ func Login(c *gin.Context) {
// ── 本地不存在该用户,尝试 LDAP 认证 ── // ── 本地不存在该用户,尝试 LDAP 认证 ──
_, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) _, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
if ldapErr != nil || ldapInfo == nil { if ldapErr != nil || ldapInfo == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) resp.Unauthorized(c, "Invalid username or password")
return return
} }
// LDAP 认证成功,自动创建或恢复本地用户 // LDAP 认证成功,自动创建或恢复本地用户
@@ -102,7 +103,7 @@ func Login(c *gin.Context) {
// FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突) // FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突)
result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser) result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser)
if result.Error != nil { 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 return
} }
// 更新已有用户的 display_name / email(LDAP 信息可能变更) // 更新已有用户的 display_name / email(LDAP 信息可能变更)
@@ -119,7 +120,7 @@ func Login(c *gin.Context) {
} }
user = newUser user = newUser
} else { } else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"}) resp.InternalMsg(c, "Database error")
return return
} }
@@ -129,7 +130,7 @@ func Login(c *gin.Context) {
user.LockedUntil = nil user.LockedUntil = nil
token, err := middleware.GenerateToken(&user) token, err := middleware.GenerateToken(&user)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"}) resp.InternalMsg(c, "Failed to generate token")
return return
} }
@@ -137,13 +138,53 @@ func Login(c *gin.Context) {
user.LastLogin = &now user.LastLogin = &now
model.DB.Save(&user) 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{ c.JSON(http.StatusOK, model.LoginResponse{
AccessToken: token, AccessToken: token,
RefreshToken: refreshToken,
TokenType: "bearer", TokenType: "bearer",
User: user, 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 // LDAPAuthInfo holds extracted user info from LDAP
type LDAPAuthInfo struct { type LDAPAuthInfo struct {
Username string Username string
@@ -258,7 +299,7 @@ func GetMe(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c) userID, _, _ := middleware.GetCurrentUser(c)
var user model.User var user model.User
if err := model.DB.First(&user, userID).Error; err != nil { 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 return
} }
c.JSON(http.StatusOK, user) c.JSON(http.StatusOK, user)
@@ -282,13 +323,13 @@ func CreateUser(c *gin.Context) {
Role string `json:"role" binding:"required"` Role string `json:"role" binding:"required"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"}) resp.InternalMsg(c, "Failed to hash password")
return return
} }
@@ -305,9 +346,9 @@ func CreateUser(c *gin.Context) {
if err := model.DB.Create(&user).Error; err != nil { if err := model.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") { 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 { } else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
} }
return return
} }
@@ -320,13 +361,13 @@ func UpdateUser(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var user model.User var user model.User
if err := model.DB.First(&user, id).Error; err != nil { 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 return
} }
var input map[string]interface{} var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -337,7 +378,7 @@ func UpdateUser(c *gin.Context) {
delete(input, "password") delete(input, "password")
if err := model.DB.Model(&user).Updates(input).Error; err != nil { 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 return
} }
@@ -350,11 +391,11 @@ func DeleteUser(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
// Protect admin (id=1) and dba (id=2) from being deleted // Protect admin (id=1) and dba (id=2) from being deleted
if id == "1" || id == "2" { 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 return
} }
if err := model.DB.Delete(&model.User{}, id).Error; err != nil { 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 return
} }
c.JSON(http.StatusOK, gin.H{"message": "User deleted"}) c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
+17 -16
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"sql-platform/internal/ghost" "sql-platform/internal/ghost"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -195,7 +196,7 @@ func GhOstAnalyzeDDL(c *gin.Context) {
Alter string `json:"alter"` Alter string `json:"alter"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -274,7 +275,7 @@ func getTableRowCount(instanceID int, database, table string) int {
// POST /api/v1/ghost/dry-run // POST /api/v1/ghost/dry-run
func GhOstDryRun(c *gin.Context) { func GhOstDryRun(c *gin.Context) {
if !isGhostEnabled() { if !isGhostEnabled() {
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"}) resp.Forbidden(c, "gh-ost 功能已禁用,请在系统设置中开启")
return return
} }
@@ -285,13 +286,13 @@ func GhOstDryRun(c *gin.Context) {
Alter string `json:"alter"` Alter string `json:"alter"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter) task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
task.DryRun = true task.DryRun = true
@@ -309,7 +310,7 @@ func GhOstDryRun(c *gin.Context) {
// POST /api/v1/ghost/execute // POST /api/v1/ghost/execute
func GhOstExecute(c *gin.Context) { func GhOstExecute(c *gin.Context) {
if !isGhostEnabled() { if !isGhostEnabled() {
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"}) resp.Forbidden(c, "gh-ost 功能已禁用,请在系统设置中开启")
return return
} }
@@ -321,13 +322,13 @@ func GhOstExecute(c *gin.Context) {
Force bool `json:"force"` // skip auto-trigger check Force bool `json:"force"` // skip auto-trigger check
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter) task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
task.DryRun = false task.DryRun = false
@@ -408,7 +409,7 @@ func GhOstStatus(c *gin.Context) {
taskID := c.Param("taskId") taskID := c.Param("taskId")
status, result := ghost.Manager.GetTaskStatus(taskID) status, result := ghost.Manager.GetTaskStatus(taskID)
if status == "" { if status == "" {
c.JSON(http.StatusNotFound, gin.H{"detail": "Task not found"}) resp.NotFound(c, "Task not found")
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -471,7 +472,7 @@ func UpdateSettings(c *gin.Context) {
Value string `json:"value"` Value string `json:"value"`
} }
if err := c.ShouldBindJSON(&items); err != nil { if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
for _, item := range items { for _, item := range items {
@@ -650,7 +651,7 @@ func SeedAuditRules(c *gin.Context) {
dbType := c.Param("dbType") dbType := c.Param("dbType")
rules, ok := defaultAuditRules[dbType] rules, ok := defaultAuditRules[dbType]
if !ok { if !ok {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的数据库类型: %s", dbType)}) resp.BadRequest(c, fmt.Sprintf("不支持的数据库类型: %s", dbType))
return return
} }
@@ -679,7 +680,7 @@ func SeedAuditRules(c *gin.Context) {
Value: &value, Value: &value,
} }
if err := model.DB.Create(&rule).Error; err != nil { 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 return
} }
count++ count++
@@ -692,12 +693,12 @@ func SeedAuditRules(c *gin.Context) {
func ToggleAuditRule(c *gin.Context) { func ToggleAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"}) resp.BadRequest(c, "无效的规则 ID")
return return
} }
var rule model.AuditRule var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil { if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"}) resp.NotFound(c, "规则不存在")
return return
} }
newEnabled := !*rule.IsEnabled newEnabled := !*rule.IsEnabled
@@ -709,12 +710,12 @@ func ToggleAuditRule(c *gin.Context) {
func UpdateAuditRule(c *gin.Context) { func UpdateAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"}) resp.BadRequest(c, "无效的规则 ID")
return return
} }
var rule model.AuditRule var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil { if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"}) resp.NotFound(c, "规则不存在")
return return
} }
var body struct { var body struct {
@@ -724,7 +725,7 @@ func UpdateAuditRule(c *gin.Context) {
Severity *string `json:"severity"` Severity *string `json:"severity"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
updates := map[string]interface{}{} updates := map[string]interface{}{}
+16 -19
View File
@@ -5,8 +5,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
"sql-platform/internal/safe"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -35,14 +37,11 @@ func ListInstances(c *gin.Context) {
} }
if err := query.Find(&instances).Error; err != nil { if err := query.Find(&instances).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
// mask passwords in response // 密码出站遮蔽由 Instance.MarshalJSON 统一收口
for i := range instances {
instances[i].Password = ""
}
c.JSON(http.StatusOK, instances) c.JSON(http.StatusOK, instances)
} }
@@ -51,10 +50,9 @@ func GetInstance(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
instance.Password = "******"
c.JSON(http.StatusOK, instance) c.JSON(http.StatusOK, instance)
} }
@@ -62,7 +60,7 @@ func GetInstance(c *gin.Context) {
func CreateInstance(c *gin.Context) { func CreateInstance(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := c.ShouldBindJSON(&instance); err != nil { if err := c.ShouldBindJSON(&instance); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
// Encrypt password before storing // Encrypt password before storing
@@ -74,11 +72,11 @@ func CreateInstance(c *gin.Context) {
} }
} }
if err := model.DB.Create(&instance).Error; err != nil { if err := model.DB.Create(&instance).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
// Auto-detect version, cluster_mode and status after creation // Auto-detect version, cluster_mode and status after creation
go func() { probe := func() {
result := testDBConnectionFull(&instance) result := testDBConnectionFull(&instance)
updates := map[string]interface{}{} updates := map[string]interface{}{}
if result.Success { if result.Success {
@@ -95,8 +93,8 @@ func CreateInstance(c *gin.Context) {
if len(updates) > 0 { if len(updates) > 0 {
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) 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) c.JSON(http.StatusCreated, instance)
} }
@@ -105,13 +103,13 @@ func UpdateInstance(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
var input map[string]interface{} var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -130,12 +128,11 @@ func UpdateInstance(c *gin.Context) {
} }
if err := model.DB.Model(&instance).Updates(input).Error; err != nil { 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 return
} }
model.DB.First(&instance, id) model.DB.First(&instance, id)
instance.Password = "******"
c.JSON(http.StatusOK, instance) c.JSON(http.StatusOK, instance)
} }
@@ -143,7 +140,7 @@ func UpdateInstance(c *gin.Context) {
func DeleteInstance(c *gin.Context) { func DeleteInstance(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := model.DB.Delete(&model.Instance{}, id).Error; err != nil { 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 return
} }
c.JSON(http.StatusOK, gin.H{"message": "Instance deleted"}) c.JSON(http.StatusOK, gin.H{"message": "Instance deleted"})
@@ -154,7 +151,7 @@ func TestConnection(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -209,7 +206,7 @@ func InstanceMetrics(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
+41 -40
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"bytes" "bytes"
@@ -28,7 +29,7 @@ func DetailListDatabases(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -39,14 +40,14 @@ func DetailListDatabases(c *gin.Context) {
db, err := getDBConnection(&instance, "") db, err := getDBConnection(&instance, "")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
rows, err := db.Query("SHOW DATABASES") rows, err := db.Query("SHOW DATABASES")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -72,7 +73,7 @@ func DetailListTables(c *gin.Context) {
dbName := c.Query("database") dbName := c.Query("database")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -87,20 +88,20 @@ func DetailListTables(c *gin.Context) {
} }
} }
if dbName == "" { if dbName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"}) resp.BadRequest(c, "database parameter required")
return return
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
rows, err := db.Query("SHOW TABLE STATUS") rows, err := db.Query("SHOW TABLE STATUS")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -139,7 +140,7 @@ func DetailDbSchema(c *gin.Context) {
dbName := c.Query("database") dbName := c.Query("database")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -154,13 +155,13 @@ func DetailDbSchema(c *gin.Context) {
} }
} }
if dbName == "" { if dbName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"}) resp.BadRequest(c, "database parameter required")
return return
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
@@ -168,7 +169,7 @@ func DetailDbSchema(c *gin.Context) {
// Get all tables and their columns // Get all tables and their columns
tableRows, err := db.Query("SHOW TABLES") tableRows, err := db.Query("SHOW TABLES")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer tableRows.Close() defer tableRows.Close()
@@ -207,7 +208,7 @@ func DetailTableDetail(c *gin.Context) {
tableName := c.Query("table") tableName := c.Query("table")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -217,13 +218,13 @@ func DetailTableDetail(c *gin.Context) {
} }
} }
if dbName == "" || tableName == "" { if dbName == "" || tableName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "database and table parameters required"}) resp.BadRequest(c, "database and table parameters required")
return return
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
@@ -235,7 +236,7 @@ func DetailTableDetail(c *gin.Context) {
// Get columns // Get columns
colRows, err := db.Query("SHOW FULL COLUMNS FROM `" + tableName + "`") colRows, err := db.Query("SHOW FULL COLUMNS FROM `" + tableName + "`")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer colRows.Close() defer colRows.Close()
@@ -306,7 +307,7 @@ func DetailProcesslist(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -321,14 +322,14 @@ func DetailProcesslist(c *gin.Context) {
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
rows, err := db.Query("SHOW PROCESSLIST") rows, err := db.Query("SHOW PROCESSLIST")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -365,7 +366,7 @@ func DetailStatus(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -383,14 +384,14 @@ func DetailStatus(c *gin.Context) {
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
rows, err := db.Query("SHOW GLOBAL STATUS") rows, err := db.Query("SHOW GLOBAL STATUS")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -429,7 +430,7 @@ func DetailKillProcess(c *gin.Context) {
pid := c.Param("pid") pid := c.Param("pid")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -439,14 +440,14 @@ func DetailKillProcess(c *gin.Context) {
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
_, err = db.Exec("KILL " + pid) _, err = db.Exec("KILL " + pid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Kill failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"}) c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"})
@@ -457,7 +458,7 @@ func DetailListUsers(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
@@ -472,7 +473,7 @@ func DetailListUsers(c *gin.Context) {
} }
db, err := getDBConnection(&instance, dbName) db, err := getDBConnection(&instance, dbName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
@@ -514,11 +515,11 @@ func ExecuteQuery(c *gin.Context) {
Offset int `json:"offset"` Offset int `json:"offset"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
if input.InstanceID == 0 || input.SQL == "" { 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 return
} }
// Read query limits from platform settings // Read query limits from platform settings
@@ -538,7 +539,7 @@ func ExecuteQuery(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { 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 return
} }
@@ -607,7 +608,7 @@ func ExecuteQuery(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
default: 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 { if pipeline != nil {
// Aggregation — add $facet for count + data // Aggregation — add $facet for count + data
facetStage := bson.D{ facetStage := bson.D{
{"$facet", bson.D{ {Key: "$facet", Value: bson.D{
{"data", bson.A{ {Key: "data", Value: bson.A{
bson.D{{"$skip", offset}}, bson.D{{Key: "$skip", Value: offset}},
bson.D{{"$limit", limit}}, bson.D{{Key: "$limit", Value: limit}},
}}, }},
{"count", bson.A{ {Key: "count", Value: bson.A{
bson.D{{"$count", "total"}}, bson.D{{Key: "$count", Value: "total"}},
}}, }},
}}, }},
} }
@@ -1404,11 +1405,11 @@ func ExportQueryCSV(c *gin.Context) {
Limit int `json:"limit"` Limit int `json:"limit"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
if input.InstanceID == 0 || input.SQL == "" { 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 return
} }
if input.Limit <= 0 { if input.Limit <= 0 {
@@ -1420,7 +1421,7 @@ func ExportQueryCSV(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { 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 return
} }
@@ -1438,7 +1439,7 @@ func ExportQueryCSV(c *gin.Context) {
case "mongodb", "mongo": case "mongodb", "mongo":
exportMongoCSV(c, &instance, input.Database, input.SQL, input.Limit) exportMongoCSV(c, &instance, input.Database, input.SQL, input.Limit)
default: 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)
} }
} }
+4 -3
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
@@ -57,7 +58,7 @@ func ListDatabases(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
databases, err := listDatabases(&instance) databases, err := listDatabases(&instance)
@@ -77,7 +78,7 @@ func ListTables(c *gin.Context) {
dbName := c.Param("db") dbName := c.Param("db")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
tables, err := listTables(&instance, dbName) tables, err := listTables(&instance, dbName)
@@ -102,7 +103,7 @@ func TestNewConnection(c *gin.Context) {
Database string `json:"database"` Database string `json:"database"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request"}) resp.BadRequest(c, "Invalid request")
return return
} }
+11 -10
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -17,7 +18,7 @@ func GetInstanceGroup(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var group model.InstanceGroup var group model.InstanceGroup
if err := model.DB.First(&group, id).Error; err != nil { 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 return
} }
c.JSON(http.StatusOK, enrichGroup(group)) c.JSON(http.StatusOK, enrichGroup(group))
@@ -34,7 +35,7 @@ func CreateInstanceGroup(c *gin.Context) {
WriteInstanceIDs []int `json:"write_instance_ids"` WriteInstanceIDs []int `json:"write_instance_ids"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -58,7 +59,7 @@ func CreateInstanceGroup(c *gin.Context) {
IsActive: &isActive, IsActive: &isActive,
} }
if err := model.DB.Create(&group).Error; err != nil { if err := model.DB.Create(&group).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -83,7 +84,7 @@ func UpdateInstanceGroup(c *gin.Context) {
gid, _ := strconv.Atoi(id) gid, _ := strconv.Atoi(id)
var group model.InstanceGroup var group model.InstanceGroup
if err := model.DB.First(&group, id).Error; err != nil { 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 return
} }
@@ -100,7 +101,7 @@ func UpdateInstanceGroup(c *gin.Context) {
WriteInstanceIDs *[]int `json:"write_instance_ids"` WriteInstanceIDs *[]int `json:"write_instance_ids"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -132,7 +133,7 @@ func UpdateInstanceGroup(c *gin.Context) {
if len(updates) > 0 { if len(updates) > 0 {
if err := model.DB.Model(&group).Updates(updates).Error; err != nil { 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 return
} }
} }
@@ -175,7 +176,7 @@ func UpdateInstanceGroup(c *gin.Context) {
func DeleteInstanceGroup(c *gin.Context) { func DeleteInstanceGroup(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := model.DB.Delete(&model.InstanceGroup{}, id).Error; err != nil { 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 return
} }
// also clean up members // also clean up members
@@ -191,7 +192,7 @@ func AssignGroupInstances(c *gin.Context) {
AccessType string `json:"access_type"` AccessType string `json:"access_type"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
if input.AccessType == "" { if input.AccessType == "" {
@@ -240,7 +241,7 @@ func AssignGroupUsers(c *gin.Context) {
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -278,7 +279,7 @@ func UpdateUserGroups(c *gin.Context) {
GroupIDs []int `json:"group_ids"` GroupIDs []int `json:"group_ids"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
+53 -29
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -46,7 +47,7 @@ func GetLDAPConfig(c *gin.Context) {
func UpdateLDAPConfig(c *gin.Context) { func UpdateLDAPConfig(c *gin.Context) {
var input map[string]interface{} var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -87,8 +88,20 @@ func UpdateLDAPConfig(c *gin.Context) {
LDAPHost: getStr("ldap_host"), LDAPHost: getStr("ldap_host"),
AdminDN: getStr("admin_dn"), AdminDN: getStr("admin_dn"),
BaseDN: getStr("base_dn"), BaseDN: getStr("base_dn"),
SearchFilter: func() *string { s := getStr("search_filter"); if s != "" { return &s }; return nil }(), SearchFilter: func() *string {
UserMapping: func() *string { s := userMappingStr; if s != "" { return &s }; return nil }(), 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 { if port := getInt("ldap_port"); port > 0 {
cfg.LDAPPort = &port cfg.LDAPPort = &port
@@ -149,13 +162,13 @@ func TestLDAPConnection(c *gin.Context) {
TestPassword string `json:"test_password"` TestPassword string `json:"test_password"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
var cfg model.LDAPConfig var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil { if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "LDAP 配置不存在,请先保存配置"}) resp.NotFound(c, "LDAP 配置不存在,请先保存配置")
return return
} }
if cfg.LDAPHost == "" { if cfg.LDAPHost == "" {
@@ -367,26 +380,26 @@ func CreateLDAPOU(c *gin.Context) {
Description string `json:"description"` Description string `json:"description"`
} }
if err := c.ShouldBindJSON(&req); err != nil || req.OuName == "" { if err := c.ShouldBindJSON(&req); err != nil || req.OuName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 ou_name 参数"}) resp.BadRequest(c, "缺少 ou_name 参数")
return return
} }
var cfg model.LDAPConfig var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil { if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) resp.BadRequest(c, "LDAP 配置不存在")
return return
} }
conn, err := dialLDAP(&cfg) conn, err := dialLDAP(&cfg)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return return
} }
defer conn.Close() defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword) adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) resp.BadRequest(c, "LDAP 管理员绑定失败")
return return
} }
@@ -399,7 +412,7 @@ func CreateLDAPOU(c *gin.Context) {
} }
if err := conn.Add(addReq); err != nil { if err := conn.Add(addReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "创建 OU 失败: " + err.Error()}) resp.BadRequest(c, "创建 OU 失败: "+err.Error())
return return
} }
@@ -416,13 +429,13 @@ func CreateLDAPUser(c *gin.Context) {
OuDn string `json:"ou_dn"` OuDn string `json:"ou_dn"`
} }
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" { 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 return
} }
var cfg model.LDAPConfig var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil { if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) resp.BadRequest(c, "LDAP 配置不存在")
return return
} }
@@ -434,14 +447,14 @@ func CreateLDAPUser(c *gin.Context) {
conn, err := dialLDAP(&cfg) conn, err := dialLDAP(&cfg)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return return
} }
defer conn.Close() defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword) adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) resp.BadRequest(c, "LDAP 管理员绑定失败")
return return
} }
@@ -475,7 +488,7 @@ func CreateLDAPUser(c *gin.Context) {
} }
if err := conn.Add(addReq); err != nil { if err := conn.Add(addReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "创建用户失败: " + err.Error()}) resp.BadRequest(c, "创建用户失败: "+err.Error())
return return
} }
@@ -496,20 +509,20 @@ func BatchImportLDAP(c *gin.Context) {
var cfg model.LDAPConfig var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil { if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) resp.BadRequest(c, "LDAP 配置不存在")
return return
} }
conn, err := dialLDAP(&cfg) conn, err := dialLDAP(&cfg)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return return
} }
defer conn.Close() defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword) adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) resp.BadRequest(c, "LDAP 管理员绑定失败")
return return
} }
@@ -534,7 +547,7 @@ func BatchImportLDAP(c *gin.Context) {
[]string{"uid", "cn", "mail"}, nil, []string{"uid", "cn", "mail"}, nil,
)) ))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 搜索失败: " + err.Error()}) resp.BadRequest(c, "LDAP 搜索失败: "+err.Error())
return return
} }
@@ -551,9 +564,14 @@ func BatchImportLDAP(c *gin.Context) {
if len(req.UserIDs) > 0 { if len(req.UserIDs) > 0 {
found := false found := false
for _, id := range req.UserIDs { 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") cn := entry.GetAttributeValue("cn")
@@ -564,8 +582,12 @@ func BatchImportLDAP(c *gin.Context) {
if err == nil { if err == nil {
// Update existing user // Update existing user
updates := map[string]interface{}{} updates := map[string]interface{}{}
if cn != "" && cn != existing.DisplayName { updates["display_name"] = cn } if cn != "" && cn != existing.DisplayName {
if mail != "" && (existing.Email == nil || *existing.Email != mail) { updates["email"] = mail } updates["display_name"] = cn
}
if mail != "" && (existing.Email == nil || *existing.Email != mail) {
updates["email"] = mail
}
if len(updates) > 0 { if len(updates) > 0 {
model.DB.Model(&existing).Updates(updates) model.DB.Model(&existing).Updates(updates)
} }
@@ -582,7 +604,9 @@ func BatchImportLDAP(c *gin.Context) {
IsActive: &isActive, IsActive: &isActive,
HashedPassword: "-", HashedPassword: "-",
} }
if mail != "" { newUser.Email = &mail } if mail != "" {
newUser.Email = &mail
}
if err := model.DB.Create(&newUser).Error; err != nil { if err := model.DB.Create(&newUser).Error; err != nil {
results = append(results, map[string]string{"uid": uid, "status": "error: " + err.Error()}) results = append(results, map[string]string{"uid": uid, "status": "error: " + err.Error()})
skipped++ skipped++
@@ -606,31 +630,31 @@ func DeleteLDAPUser(c *gin.Context) {
DN string `json:"dn" binding:"required"` DN string `json:"dn" binding:"required"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 dn 参数"}) resp.BadRequest(c, "缺少 dn 参数")
return return
} }
var cfg model.LDAPConfig var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil { if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) resp.BadRequest(c, "LDAP 配置不存在")
return return
} }
conn, err := dialLDAP(&cfg) conn, err := dialLDAP(&cfg)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return return
} }
defer conn.Close() defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword) adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) resp.BadRequest(c, "LDAP 管理员绑定失败")
return return
} }
if err := conn.Del(ldap.NewDelRequest(req.DN, nil)); err != nil { 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 return
} }
+10 -9
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -63,11 +64,11 @@ func CreateNotification(c *gin.Context) {
IsEnabled bool `json:"is_enabled"` IsEnabled bool `json:"is_enabled"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"}) resp.BadRequest(c, "参数错误")
return return
} }
if input.Name == "" { if input.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "名称不能为空"}) resp.BadRequest(c, "名称不能为空")
return return
} }
cfgBytes, _ := json.Marshal(input.Config) cfgBytes, _ := json.Marshal(input.Config)
@@ -79,7 +80,7 @@ func CreateNotification(c *gin.Context) {
IsEnabled: &enabled, IsEnabled: &enabled,
} }
if err := model.DB.Create(&ch).Error; err != nil { if err := model.DB.Create(&ch).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"}) resp.InternalMsg(c, "创建失败")
return return
} }
c.JSON(http.StatusOK, toChannelResponse(ch)) c.JSON(http.StatusOK, toChannelResponse(ch))
@@ -90,12 +91,12 @@ func UpdateNotification(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil { if err := model.DB.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"}) resp.NotFound(c, "通知渠道不存在")
return return
} }
var input map[string]interface{} var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"}) resp.BadRequest(c, "参数错误")
return return
} }
if name, ok := input["name"].(string); ok { if name, ok := input["name"].(string); ok {
@@ -127,7 +128,7 @@ func TestNotificationSend(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil { if err := model.DB.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"}) resp.NotFound(c, "通知渠道不存在")
return return
} }
@@ -135,7 +136,7 @@ func TestNotificationSend(c *gin.Context) {
_ = json.Unmarshal([]byte(ch.Config), &cfg) _ = json.Unmarshal([]byte(ch.Config), &cfg)
webhookURL, _ := cfg["webhook_url"].(string) webhookURL, _ := cfg["webhook_url"].(string)
if webhookURL == "" { if webhookURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Webhook URL 未配置"}) resp.BadRequest(c, "Webhook URL 未配置")
return return
} }
@@ -150,12 +151,12 @@ func TestNotificationSend(c *gin.Context) {
secret, _ := cfg["secret"].(string) secret, _ := cfg["secret"].(string)
sendErr = sendDingtalkWebhook(webhookURL, secret, title, body) sendErr = sendDingtalkWebhook(webhookURL, secret, title, body)
default: default:
c.JSON(http.StatusBadRequest, gin.H{"detail": "不支持的通知类型: " + ch.ChannelType}) resp.BadRequest(c, "不支持的通知类型: "+ch.ChannelType)
return return
} }
if sendErr != nil { if sendErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()}) resp.InternalErr(c, sendErr)
return return
} }
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试消息已发送"}) c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试消息已发送"})
+4 -3
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
@@ -14,7 +15,7 @@ func GetUserPreferences(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c) userID, _, _ := middleware.GetCurrentUser(c)
var user model.User var user model.User
if err := model.DB.First(&user, userID).Error; err != nil { 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 return
} }
@@ -31,7 +32,7 @@ func UpdateUserPreferences(c *gin.Context) {
var prefs map[string]string var prefs map[string]string
if err := c.ShouldBindJSON(&prefs); err != nil { if err := c.ShouldBindJSON(&prefs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -40,7 +41,7 @@ func UpdateUserPreferences(c *gin.Context) {
result := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("preferences", str) result := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("preferences", str)
if result.Error != nil { if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": result.Error.Error()}) resp.InternalErr(c, result.Error)
return return
} }
+35 -34
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -384,13 +385,13 @@ func RedisInfo(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, 0) client, err := getRedisClient(&instance, 0)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -398,7 +399,7 @@ func RedisInfo(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
info, err := client.Info(ctx).Result() info, err := client.Info(ctx).Result()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -460,13 +461,13 @@ func RedisDbsize(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -474,7 +475,7 @@ func RedisDbsize(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
dbSize, err := client.DBSize(ctx).Result() dbSize, err := client.DBSize(ctx).Result()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{"db_size": dbSize}) c.JSON(http.StatusOK, gin.H{"db_size": dbSize})
@@ -510,13 +511,13 @@ func RedisScanKeys(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -549,7 +550,7 @@ func RedisScanKeys(c *gin.Context) {
allKeys, err := client.ScanAllKeys(ctx, pattern, count) allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -586,13 +587,13 @@ func RedisGetKeyDetail(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -636,13 +637,13 @@ func RedisSlowlog(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, 0) client, err := getRedisClient(&instance, 0)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -650,7 +651,7 @@ func RedisSlowlog(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
logs, err := client.SlowLogGet(ctx, int64(count)).Result() logs, err := client.SlowLogGet(ctx, int64(count)).Result()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -675,13 +676,13 @@ func RedisClients(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, 0) client, err := getRedisClient(&instance, 0)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -689,7 +690,7 @@ func RedisClients(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
clientList, err := client.ClientList(ctx).Result() clientList, err := client.ClientList(ctx).Result()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -729,19 +730,19 @@ func RedisExecuteCommand(c *gin.Context) {
Args []string `json:"args"` Args []string `json:"args"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -760,7 +761,7 @@ func RedisExecuteCommand(c *gin.Context) {
} }
if !readOnly[cmd] { 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 return
} }
@@ -803,19 +804,19 @@ func RedisCreateKey(c *gin.Context) {
TTL int `json:"ttl"` TTL int `json:"ttl"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -823,7 +824,7 @@ func RedisCreateKey(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
if err := writeRedisKeyValue(ctx, client, input.Key, input.Type, input.Value); err != nil { 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 return
} }
@@ -850,30 +851,30 @@ func RedisUpdateKey(c *gin.Context) {
TTL int `json:"ttl"` TTL int `json:"ttl"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
ctx := context.Background() ctx := context.Background()
if err := client.Del(ctx, key).Err(); err != nil { if err := client.Del(ctx, key).Err(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
if err := writeRedisKeyValue(ctx, client, key, input.Type, input.Value); err != nil { 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 return
} }
if input.TTL > 0 { if input.TTL > 0 {
@@ -895,13 +896,13 @@ func RedisDeleteKey(c *gin.Context) {
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil { 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 return
} }
client, err := getRedisClient(&instance, db) client, err := getRedisClient(&instance, db)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
defer client.Close() defer client.Close()
@@ -909,7 +910,7 @@ func RedisDeleteKey(c *gin.Context) {
ctx := context.Background() ctx := context.Background()
deleted, err := client.Del(ctx, key).Result() deleted, err := client.Del(ctx, key).Result()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "Key deleted", "deleted": deleted}) 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")) id, _ := strconv.Atoi(c.Param("id"))
var wf model.WorkflowDefinition var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil { 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 return
} }
// clone // clone
+70
View File
@@ -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})
}
+4 -3
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"time" "time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
@@ -71,19 +72,19 @@ func CancelScheduledTask(c *gin.Context) {
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
// Only the applicant or admin/dba can cancel // Only the applicant or admin/dba can cancel
if ticket.ApplicantID != userID && role != "admin" && role != "dba" { if ticket.ApplicantID != userID && role != "admin" && role != "dba" {
c.JSON(http.StatusForbidden, gin.H{"detail": "Permission denied"}) resp.Forbidden(c, "Permission denied")
return return
} }
// Only allow cancellation of scheduled tickets // Only allow cancellation of scheduled tickets
if !ticket.ScheduledAt.Valid { if !ticket.ScheduledAt.Valid {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket is not scheduled"}) resp.BadRequest(c, "Ticket is not scheduled")
return return
} }
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,7 +21,7 @@ func AuditTicketSQL(c *gin.Context) {
Database string `json:"database"` Database string `json:"database"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -184,7 +185,7 @@ func SQLFormat(c *gin.Context) {
SQL string `json:"sql" binding:"required"` SQL string `json:"sql" binding:"required"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -200,7 +201,7 @@ func SQLAnalyze(c *gin.Context) {
DBType string `json:"db_type"` DBType string `json:"db_type"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
+40 -31
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"sql-platform/internal/ghost" "sql-platform/internal/ghost"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
@@ -91,8 +92,12 @@ func ListTickets(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) limit, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 { page = 1 } if page < 1 {
if limit < 1 || limit > 100 { limit = 20 } page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
query := model.DB.Model(&model.Ticket{}).Order("id DESC") query := model.DB.Model(&model.Ticket{}).Order("id DESC")
@@ -162,7 +167,7 @@ func GetTicket(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
@@ -223,7 +228,7 @@ func CreateTicket(c *gin.Context) {
// auditor cannot create tickets // auditor cannot create tickets
if userRole == "auditor" { if userRole == "auditor" {
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审计员不能提交工单"}) resp.Forbidden(c, "权限不足:审计员不能提交工单")
return return
} }
@@ -242,7 +247,7 @@ func CreateTicket(c *gin.Context) {
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -252,7 +257,7 @@ func CreateTicket(c *gin.Context) {
readOnlyPrefixes := []string{"SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"} readOnlyPrefixes := []string{"SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"}
for _, prefix := range readOnlyPrefixes { for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(upperSQL, prefix) { if strings.HasPrefix(upperSQL, prefix) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能"}) resp.BadRequest(c, "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能")
return return
} }
} }
@@ -267,7 +272,7 @@ func CreateTicket(c *gin.Context) {
} }
for _, ro := range redisReadOnlyCmds { for _, ro := range redisReadOnlyCmds {
if cmdUpper == ro { if cmdUpper == ro {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper)}) resp.BadRequest(c, fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper))
return return
} }
} }
@@ -379,7 +384,7 @@ func CreateTicket(c *gin.Context) {
} }
if err := model.DB.Create(&ticket).Error; err != nil { if err := model.DB.Create(&ticket).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -476,13 +481,13 @@ func ApproveTicket(c *gin.Context) {
// Role check: admin/dba/developer can approve // Role check: admin/dba/developer can approve
if userRole != "admin" && userRole != "dba" && userRole != "developer" { if userRole != "admin" && userRole != "dba" && userRole != "developer" {
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审核操作需要管理员、DBA 或开发者角色"}) resp.Forbidden(c, "权限不足:审核操作需要管理员、DBA 或开发者角色")
return return
} }
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
@@ -491,7 +496,7 @@ func ApproveTicket(c *gin.Context) {
Comment *string `json:"comment"` Comment *string `json:"comment"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -501,7 +506,7 @@ func ApproveTicket(c *gin.Context) {
var approval model.TicketApproval 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 { 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 // Already approved or no pending approval for this user
c.JSON(http.StatusBadRequest, gin.H{"detail": "没有待处理的审批记录(你可能已经审批过了)"}) resp.BadRequest(c, "没有待处理的审批记录(你可能已经审批过了)")
return return
} }
@@ -539,19 +544,19 @@ func ExecuteTicket(c *gin.Context) {
// Role check: only admin/dba can execute // Role check: only admin/dba can execute
_, _, userRole := middleware.GetCurrentUser(c) _, _, userRole := middleware.GetCurrentUser(c)
if userRole != "admin" && userRole != "dba" { if userRole != "admin" && userRole != "dba" {
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:执行操作需要管理员或 DBA 角色"}) resp.Forbidden(c, "权限不足:执行操作需要管理员或 DBA 角色")
return return
} }
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
// Check ticket status — allow approved OR reviewing (will auto-approve execute step) // Check ticket status — allow approved OR reviewing (will auto-approve execute step)
if ticket.Status != nil && *ticket.Status != "approved" && *ticket.Status != "reviewing" { 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 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). Where("ticket_id = ? AND step_type = 'execute' AND (approver_id = ? OR approver_id IS NULL)", id, userID).
Count(&executorCheck) Count(&executorCheck)
if executorCheck == 0 { if executorCheck == 0 {
c.JSON(http.StatusForbidden, gin.H{"detail": "你不是此工单的执行人"}) resp.Forbidden(c, "你不是此工单的执行人")
return return
} }
@@ -587,7 +592,7 @@ func ExecuteTicket(c *gin.Context) {
Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id). Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id).
Count(&pendingAudit) Count(&pendingAudit)
if pendingAudit > 0 { if pendingAudit > 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "还有未完成的审核步骤"}) resp.BadRequest(c, "还有未完成的审核步骤")
return return
} }
@@ -597,14 +602,14 @@ func ExecuteTicket(c *gin.Context) {
Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id). Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id).
Count(&rejectedAudit) Count(&rejectedAudit)
if rejectedAudit > 0 { if rejectedAudit > 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "审核已被拒绝,无法执行"}) resp.BadRequest(c, "审核已被拒绝,无法执行")
return return
} }
// Get instance info // Get instance info
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil { 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 return
} }
@@ -930,7 +935,6 @@ func parseAlterTable(sql string) (string, string, error) {
return tableName, alterClause, nil return tableName, alterClause, nil
} }
// POST /api/v1/tickets/:id/cancel // POST /api/v1/tickets/:id/cancel
func CancelTicket(c *gin.Context) { func CancelTicket(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
@@ -938,7 +942,7 @@ func CancelTicket(c *gin.Context) {
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
@@ -946,13 +950,13 @@ func CancelTicket(c *gin.Context) {
if ticket.ApplicantID != userID { if ticket.ApplicantID != userID {
_, _, role := middleware.GetCurrentUser(c) _, _, role := middleware.GetCurrentUser(c)
if role != "admin" { 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 return
} }
} }
if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing" && *ticket.Status != "approved") { if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing" && *ticket.Status != "approved") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "只能撤销待审核、审核中或已通过(待执行)的工单"}) resp.BadRequest(c, "只能撤销待审核、审核中或已通过(待执行)的工单")
return return
} }
@@ -971,30 +975,30 @@ func RollbackTicket(c *gin.Context) {
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
if ticket.RollbackSQL == nil || *ticket.RollbackSQL == "" { 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 return
} }
// Get instance // Get instance
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil { 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 return
} }
if ticket.DBType == "redis" { if ticket.DBType == "redis" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Redis rollback not supported"}) resp.BadRequest(c, "Redis rollback not supported")
return return
} }
db, err := getDBConnection(&instance, ticket.TargetDatabase) db, err := getDBConnection(&instance, ticket.TargetDatabase)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) resp.InternalErr(c, err)
return return
} }
defer db.Close() defer db.Close()
@@ -1037,7 +1041,7 @@ func GetTicketTimeline(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var ticket model.Ticket var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil { 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 return
} }
@@ -1095,7 +1099,12 @@ func GetTicketTimeline(c *gin.Context) {
ID: 99, ID: 99,
Action: execAction, Action: execAction,
Timestamp: ticket.ExecuteTime.Format(time.RFC3339), 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 ""
}(),
}) })
} }
@@ -1108,7 +1117,7 @@ func GetTicketApprovals(c *gin.Context) {
var approvals []model.TicketApproval var approvals []model.TicketApproval
if err := model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals).Error; err != nil { 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 return
} }
+13 -12
View File
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model" "sql-platform/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,7 +16,7 @@ func GetWorkflow(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var wf model.WorkflowDefinition var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil { 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 return
} }
var steps []model.WorkflowStep var steps []model.WorkflowStep
@@ -55,7 +56,7 @@ func CreateWorkflow(c *gin.Context) {
} `json:"steps"` } `json:"steps"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -71,7 +72,7 @@ func CreateWorkflow(c *gin.Context) {
IsDefault: input.IsDefault, IsDefault: input.IsDefault,
} }
if err := model.DB.Create(&wf).Error; err != nil { if err := model.DB.Create(&wf).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) resp.InternalErr(c, err)
return return
} }
@@ -99,13 +100,13 @@ func UpdateWorkflow(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
var wf model.WorkflowDefinition var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil { 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 return
} }
var input map[string]interface{} var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -171,7 +172,7 @@ func ResetPassword(c *gin.Context) {
NewPassword string `json:"new_password" binding:"required"` NewPassword string `json:"new_password" binding:"required"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
_, _ = id, input.NewPassword _, _ = id, input.NewPassword
@@ -193,7 +194,7 @@ func BatchImportUsers(c *gin.Context) {
SkipExisting bool `json:"skip_existing"` SkipExisting bool `json:"skip_existing"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -276,7 +277,7 @@ func BatchImportLdapUsers(c *gin.Context) {
} `json:"users"` } `json:"users"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
@@ -359,21 +360,21 @@ func ResetUserPassword(c *gin.Context) {
NewPassword string `json:"new_password" binding:"required"` NewPassword string `json:"new_password" binding:"required"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) resp.BadRequest(c, err.Error())
return return
} }
id, err := strconv.Atoi(userId) id, err := strconv.Atoi(userId)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid user ID"}) resp.BadRequest(c, "Invalid user ID")
return return
} }
hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Hash failed"}) resp.InternalMsg(c, "Hash failed")
return return
} }
if err := model.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hash)).Error; err != nil { 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 return
} }
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
+75
View File
@@ -1,6 +1,9 @@
package middleware package middleware
import ( import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -13,6 +16,71 @@ import (
"github.com/golang-jwt/jwt/v5" "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, // getSessionTimeoutHours reads session_timeout_hours from system_settings,
// falls back to config/env, then default 24h. // falls back to config/env, then default 24h.
func getSessionTimeoutHours() int { func getSessionTimeoutHours() int {
@@ -35,6 +103,13 @@ type Claims struct {
jwt.RegisteredClaims 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) { func GenerateToken(user *model.User) (string, error) {
claims := Claims{ claims := Claims{
UserID: user.ID, UserID: user.ID,
+25
View File
@@ -2,6 +2,7 @@ package model
import ( import (
"database/sql" "database/sql"
"encoding/json"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -57,6 +58,15 @@ type Instance struct {
func (Instance) TableName() string { return "instances" } 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 ==================== // ==================== Ticket ====================
type Ticket struct { type Ticket struct {
@@ -494,6 +504,20 @@ type UserGroup struct {
func (UserGroup) TableName() string { return "user_groups" } 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 ==================== // ==================== DTO ====================
type LoginRequest struct { type LoginRequest struct {
@@ -503,6 +527,7 @@ type LoginRequest struct {
type LoginResponse struct { type LoginResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
User User `json:"user"` User User `json:"user"`
} }
+20
View File
@@ -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()
}()
}
-153
View File
@@ -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
}
@@ -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
}
-131
View File
@@ -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)
}
+2
View File
@@ -12,6 +12,7 @@ func Setup(r *gin.Engine) {
// Public routes // Public routes
api.POST("/auth/login", handler.Login) api.POST("/auth/login", handler.Login)
api.POST("/auth/refresh", handler.RefreshToken)
api.GET("/public/login-style", handler.GetPublicLoginStyle) api.GET("/public/login-style", handler.GetPublicLoginStyle)
// Authenticated routes // Authenticated routes
@@ -20,6 +21,7 @@ func Setup(r *gin.Engine) {
{ {
// Auth // Auth
auth.GET("/auth/me", handler.GetMe) auth.GET("/auth/me", handler.GetMe)
auth.POST("/auth/logout", handler.Logout)
// Dashboard // Dashboard
auth.GET("/dashboard/stats", handler.DashboardStats) auth.GET("/dashboard/stats", handler.DashboardStats)