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

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

No files matched your search

+4
View File
@@ -58,11 +58,15 @@ func main() {
&model.ScheduledTask{},
&model.ScheduledTaskLog{},
&model.UserGroup{},
&model.RefreshToken{},
)
// Seed default data (idempotent): users, workflow, settings
seedAll(db)
// 治理默认 JWT 密钥(生产模式自动生成并持久化)
config.EnsureJWTSecret()
// Setup Gin
r := gin.Default()
r.Use(middleware.CORSMiddleware())
+51 -7
View File
@@ -1,19 +1,27 @@
package config
import (
"crypto/rand"
"encoding/hex"
"log"
"os"
"strconv"
"sql-platform/internal/model"
"github.com/joho/godotenv"
)
// DefaultJWTSecret 仅用于本地 DEBUG;生产模式(DEBUG=false)会被 EnsureJWTSecret 替换为随机密钥
const DefaultJWTSecret = "sql-platform-secret-key-change-in-production"
type Config struct {
Port string
DBPath string
JWTSecret string
JWTExpire int // hours
GhOstPath string
Debug bool
Port string
DBPath string
JWTSecret string
JWTExpire int // hours
GhOstPath string
Debug bool
}
var C Config
@@ -24,13 +32,49 @@ func Init() {
C = Config{
Port: getEnv("PORT", "9000"),
DBPath: getEnv("DB_PATH", "../backend/sqlite3.db"),
JWTSecret: getEnv("JWT_SECRET", "sql-platform-secret-key-change-in-production"),
JWTSecret: getEnv("JWT_SECRET", DefaultJWTSecret),
JWTExpire: getEnvInt("JWT_EXPIRE_HOURS", 24),
GhOstPath: getEnv("GH_OST_PATH", "gh-ost"),
Debug: getEnvBool("DEBUG", true),
}
}
// EnsureJWTSecret 治理默认 JWT 密钥:
// - 显式配置了 JWT_SECRET(非默认值)→ 直接使用
// - DEBUG 模式使用默认密钥 → 打印告警(开发便利优先)
// - 生产模式使用默认密钥 → 自动生成随机密钥并持久化到 system_settings(重启复用;
// 首次切换会使存量会话失效一次)
//
// 必须在 model.DB 初始化并完成 AutoMigrate 之后调用。
func EnsureJWTSecret() {
if C.JWTSecret != DefaultJWTSecret {
return
}
if C.Debug {
log.Println("[WARN] 正在使用默认 JWT 密钥(DEBUG 模式);生产环境请设置 JWT_SECRET 环境变量")
return
}
var s model.SystemSetting
if err := model.DB.Where("`key` = ?", "jwt_secret").Take(&s).Error; err == nil && s.Value != "" {
C.JWTSecret = s.Value
return
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
log.Fatalf("生成随机 JWT 密钥失败: %v", err)
}
secret := hex.EncodeToString(buf)
if err := model.DB.Where(model.SystemSetting{Key: "jwt_secret"}).
Assign(model.SystemSetting{Value: secret, ValueType: "string", Group: "security", Label: "JWT Secret"}).
FirstOrCreate(&s).Error; err != nil {
log.Fatalf("持久化 JWT 密钥失败: %v", err)
}
C.JWTSecret = secret
log.Println("[INFO] 已自动生成随机 JWT 密钥并持久化(未显式配置 JWT_SECRET 的生产部署,存量会话将失效一次)")
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
+31 -22
View File
@@ -5,31 +5,34 @@ import (
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"sql-platform/internal/safe"
)
// GhOstTask defines a gh-ost execution task
type GhOstTask struct {
Host string `json:"host"`
Port int `json:"port,omitempty"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
Table string `json:"table"`
Alter string `json:"alter"` // e.g. "ADD COLUMN status INT NOT NULL DEFAULT 0"
ChunkSize int `json:"chunk_size,omitempty"`
MaxLoad string `json:"max_load,omitempty"`
CriticalLoad string `json:"critical_load,omitempty"`
MaxLagMillis int `json:"max_lag_millis,omitempty"`
AllowOnMaster bool `json:"allow_on_master"`
DryRun bool `json:"dry_run"`
Execute bool `json:"execute"`
ExactRowCount bool `json:"exact_row_count"`
PostponeCutOver bool `json:"postpone_cut_over"`
AliyunRDS bool `json:"aliyun_rds,omitempty"` // --aliyun-rds
AWSAurora bool `json:"aws_aurora,omitempty"` // --aws
Host string `json:"host"`
Port int `json:"port,omitempty"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
Table string `json:"table"`
Alter string `json:"alter"` // e.g. "ADD COLUMN status INT NOT NULL DEFAULT 0"
ChunkSize int `json:"chunk_size,omitempty"`
MaxLoad string `json:"max_load,omitempty"`
CriticalLoad string `json:"critical_load,omitempty"`
MaxLagMillis int `json:"max_lag_millis,omitempty"`
AllowOnMaster bool `json:"allow_on_master"`
DryRun bool `json:"dry_run"`
Execute bool `json:"execute"`
ExactRowCount bool `json:"exact_row_count"`
PostponeCutOver bool `json:"postpone_cut_over"`
AliyunRDS bool `json:"aliyun_rds,omitempty"` // --aliyun-rds
AWSAurora bool `json:"aws_aurora,omitempty"` // --aws
}
// GhOstResult holds the execution result
@@ -121,7 +124,7 @@ func RunGhOst(ctx context.Context, task GhOstTask, ghOstPath string) (*GhOstResu
err := cmd.Run()
result := &GhOstResult{
Command: "gh-ost " + strings.Join(args, " "),
Command: "gh-ost " + maskSecrets(strings.Join(args, " ")),
Duration: time.Since(start).String(),
Output: stdout.String(),
}
@@ -145,7 +148,7 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string
Manager.tasks[taskID] = &GhOstResult{TaskID: taskID}
Manager.mu.Unlock()
go func() {
run := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
@@ -165,7 +168,7 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string
result := &GhOstResult{
TaskID: taskID,
Command: "gh-ost " + strings.Join(args, " "),
Command: "gh-ost " + maskSecrets(strings.Join(args, " ")),
Duration: time.Since(start).String(),
Output: stdout.String(),
Success: err == nil,
@@ -183,11 +186,17 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string
if cb != nil {
cb(taskID, result.Output)
}
}()
}
safe.Go("ghost:"+taskID, run)
return taskID
}
// maskSecrets 将命令行中的密码参数值替换为 ******,防止经任务状态接口回传前端
func maskSecrets(cmdline string) string {
return regexp.MustCompile(`(--password=)([^ ]+)`).ReplaceAllString(cmdline, `$1******`)
}
// GetTaskStatus returns the status of an async task
func (m *TaskManager) GetTaskStatus(taskID string) (string, *GhOstResult) {
m.mu.RLock()
+62 -21
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -22,7 +23,7 @@ import (
func Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "username and password required"})
resp.BadRequest(c, "username and password required")
return
}
@@ -34,7 +35,7 @@ func Login(c *gin.Context) {
maxAttempts := getSettingInt("login_max_attempts", 5)
lockMin := getSettingInt("login_lockout_minutes", 30)
remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1
c.JSON(http.StatusTooManyRequests, gin.H{"detail": fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), "retry_after_minutes": remaining})
resp.TooManyRequests(c, fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), remaining)
return
}
// Lock expired → reset
@@ -47,7 +48,7 @@ func Login(c *gin.Context) {
if err == nil {
// ── 本地用户存在 ──
if user.IsActive != nil && !*user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
resp.Forbidden(c, "Account is disabled")
return
}
@@ -60,7 +61,7 @@ func Login(c *gin.Context) {
if ldapErr != nil || ldapInfo == nil {
// Both local and LDAP failed → record failed attempt
handleFailedLogin(&user)
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
resp.Unauthorized(c, "Invalid username or password")
return
}
// LDAP OK — update local user info
@@ -81,7 +82,7 @@ func Login(c *gin.Context) {
// ── 本地不存在该用户,尝试 LDAP 认证 ──
_, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
if ldapErr != nil || ldapInfo == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
resp.Unauthorized(c, "Invalid username or password")
return
}
// LDAP 认证成功,自动创建或恢复本地用户
@@ -102,7 +103,7 @@ func Login(c *gin.Context) {
// FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突)
result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user: " + result.Error.Error()})
resp.InternalErr(c, result.Error)
return
}
// 更新已有用户的 display_name / email(LDAP 信息可能变更)
@@ -119,7 +120,7 @@ func Login(c *gin.Context) {
}
user = newUser
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
resp.InternalMsg(c, "Database error")
return
}
@@ -129,7 +130,7 @@ func Login(c *gin.Context) {
user.LockedUntil = nil
token, err := middleware.GenerateToken(&user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
resp.InternalMsg(c, "Failed to generate token")
return
}
@@ -137,13 +138,53 @@ func Login(c *gin.Context) {
user.LastLogin = &now
model.DB.Save(&user)
// 签发 refresh token(失败不阻塞登录,仅记录)
refreshToken, refreshErr := middleware.GenerateRefreshToken(user.ID)
if refreshErr != nil {
fmt.Printf("generate refresh token failed: %v\n", refreshErr)
}
c.JSON(http.StatusOK, model.LoginResponse{
AccessToken: token,
TokenType: "bearer",
User: user,
AccessToken: token,
RefreshToken: refreshToken,
TokenType: "bearer",
User: user,
})
}
// POST /api/v1/auth/refresh — 用 refresh_token 轮换新的 token 对
func RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "refresh_token required")
return
}
user, newRefresh, err := middleware.ConsumeRefreshToken(req.RefreshToken)
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
return
}
access, err := middleware.GenerateToken(user)
if err != nil {
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": access,
"refresh_token": newRefresh,
"token_type": "bearer",
})
}
// POST /api/v1/auth/logout — 撤销当前用户全部 refresh token
func Logout(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
middleware.RevokeUserRefreshTokens(userID)
resp.Message(c, "Logged out")
}
// LDAPAuthInfo holds extracted user info from LDAP
type LDAPAuthInfo struct {
Username string
@@ -258,7 +299,7 @@ func GetMe(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
resp.NotFound(c, "User not found")
return
}
c.JSON(http.StatusOK, user)
@@ -282,13 +323,13 @@ func CreateUser(c *gin.Context) {
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"})
resp.InternalMsg(c, "Failed to hash password")
return
}
@@ -305,9 +346,9 @@ func CreateUser(c *gin.Context) {
if err := model.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
resp.BadRequest(c, "Username already exists")
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
}
return
}
@@ -320,13 +361,13 @@ func UpdateUser(c *gin.Context) {
id := c.Param("id")
var user model.User
if err := model.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
resp.NotFound(c, "User not found")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -337,7 +378,7 @@ func UpdateUser(c *gin.Context) {
delete(input, "password")
if err := model.DB.Model(&user).Updates(input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -350,11 +391,11 @@ func DeleteUser(c *gin.Context) {
id := c.Param("id")
// Protect admin (id=1) and dba (id=2) from being deleted
if id == "1" || id == "2" {
c.JSON(http.StatusForbidden, gin.H{"detail": "Cannot delete system reserved users"})
resp.Forbidden(c, "Cannot delete system reserved users")
return
}
if err := model.DB.Delete(&model.User{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
+11 -11
View File
@@ -32,19 +32,19 @@ func DashboardStats(c *gin.Context) {
model.DB.Model(&model.Ticket{}).Select("db_type as db_type, count(*) as count").Group("db_type").Scan(&typeDist)
c.JSON(http.StatusOK, gin.H{
"ticket_total": ticketTotal,
"ticket_pending": ticketPending,
"ticket_month": ticketTotal, // 简化
"ticket_executing": ticketExecuting,
"instance_total": instanceTotal,
"instance_online": instanceOnline,
"user_total": userTotal,
"queries_today": queriesToday,
"ticket_total": ticketTotal,
"ticket_pending": ticketPending,
"ticket_month": ticketTotal, // 简化
"ticket_executing": ticketExecuting,
"instance_total": instanceTotal,
"instance_online": instanceOnline,
"user_total": userTotal,
"queries_today": queriesToday,
"type_distribution": typeDist,
"query_stats": gin.H{
"total": queriesToday,
"avg_time_ms": 0,
"slow_count": 0,
"total": queriesToday,
"avg_time_ms": 0,
"slow_count": 0,
"type_distribution": typeDist,
},
})
+45 -44
View File
@@ -8,6 +8,7 @@ import (
"time"
"sql-platform/internal/ghost"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -37,18 +38,18 @@ func getGhostPath() string {
// getGhostConfig returns all ghost-related settings as a map for API responses
func getGhostConfig() gin.H {
return gin.H{
"enabled": isGhostEnabled(),
"default_mode": getSetting("ghost_default_mode", "dry_run"),
"osc_priority": getSetting("ghost_osc_priority", "native_first"),
"env_mode": getSetting("ghost_env_mode", "development"),
"cloud_type": getSetting("ghost_cloud_type", "self_hosted"),
"auto_trigger_rows": getSettingInt("ghost_auto_trigger_rows", 10),
"auto_trigger_ddl": getSetting("ghost_auto_trigger_ddl", "ADD COLUMN,MODIFY COLUMN,ADD INDEX"),
"chunk_size": getSettingInt("ghost_default_chunk_size", 1000),
"max_load": getSetting("ghost_max_load", "Threads_running=25"),
"critical_load": getSetting("ghost_critical_load", "Threads_running=50"),
"allow_on_master": getSetting("ghost_allow_on_master", "true") == "true",
"max_lag_millis": getSettingInt("ghost_max_lag_millis", 1500),
"enabled": isGhostEnabled(),
"default_mode": getSetting("ghost_default_mode", "dry_run"),
"osc_priority": getSetting("ghost_osc_priority", "native_first"),
"env_mode": getSetting("ghost_env_mode", "development"),
"cloud_type": getSetting("ghost_cloud_type", "self_hosted"),
"auto_trigger_rows": getSettingInt("ghost_auto_trigger_rows", 10),
"auto_trigger_ddl": getSetting("ghost_auto_trigger_ddl", "ADD COLUMN,MODIFY COLUMN,ADD INDEX"),
"chunk_size": getSettingInt("ghost_default_chunk_size", 1000),
"max_load": getSetting("ghost_max_load", "Threads_running=25"),
"critical_load": getSetting("ghost_critical_load", "Threads_running=50"),
"allow_on_master": getSetting("ghost_allow_on_master", "true") == "true",
"max_lag_millis": getSettingInt("ghost_max_lag_millis", 1500),
}
}
@@ -64,10 +65,10 @@ func getSettingInt(key string, fallback int) int {
// DDLAnalysis contains the analysis result of an ALTER statement
type DDLAnalysis struct {
DDLTypes []string // extracted DDL operation types
NeedsOSC bool // whether OSC tool is recommended
Reason string // explanation for the recommendation
RecommendedTool string // "native", "gh-ost", "pt-osc"
DDLTypes []string // extracted DDL operation types
NeedsOSC bool // whether OSC tool is recommended
Reason string // explanation for the recommendation
RecommendedTool string // "native", "gh-ost", "pt-osc"
}
// analyzeDDL inspects the ALTER statement to determine DDL types and OSC need
@@ -81,7 +82,7 @@ func analyzeDDL(alterStmt string) DDLAnalysis {
"ADD INDEX": "ADD INDEX", "DROP INDEX": "DROP INDEX",
"ADD UNIQUE": "ADD INDEX", "ADD PRIMARY KEY": "ADD INDEX",
"DROP PRIMARY KEY": "DROP INDEX",
"RENAME COLUMN": "CHANGE COLUMN", "ALGORITHM": "ALGORITHM",
"RENAME COLUMN": "CHANGE COLUMN", "ALGORITHM": "ALGORITHM",
}
for pattern, ddlType := range ddls {
if strings.Contains(upper, pattern) {
@@ -195,7 +196,7 @@ func GhOstAnalyzeDDL(c *gin.Context) {
Alter string `json:"alter"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -234,16 +235,16 @@ func GhOstAnalyzeDDL(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"enabled": isGhostEnabled(),
"ddl_types": analysis.DDLTypes,
"needs_osc": analysis.NeedsOSC,
"recommended_tool": analysis.RecommendedTool,
"reason": analysis.Reason,
"table_rows": rowCount,
"mysql_version": mysqlVersion,
"supports_instant": instantDDL,
"cloud_type": cloudType,
"config": getGhostConfig(),
"enabled": isGhostEnabled(),
"ddl_types": analysis.DDLTypes,
"needs_osc": analysis.NeedsOSC,
"recommended_tool": analysis.RecommendedTool,
"reason": analysis.Reason,
"table_rows": rowCount,
"mysql_version": mysqlVersion,
"supports_instant": instantDDL,
"cloud_type": cloudType,
"config": getGhostConfig(),
})
}
@@ -274,7 +275,7 @@ func getTableRowCount(instanceID int, database, table string) int {
// POST /api/v1/ghost/dry-run
func GhOstDryRun(c *gin.Context) {
if !isGhostEnabled() {
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"})
resp.Forbidden(c, "gh-ost 功能已禁用,请在系统设置中开启")
return
}
@@ -285,13 +286,13 @@ func GhOstDryRun(c *gin.Context) {
Alter string `json:"alter"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task.DryRun = true
@@ -309,7 +310,7 @@ func GhOstDryRun(c *gin.Context) {
// POST /api/v1/ghost/execute
func GhOstExecute(c *gin.Context) {
if !isGhostEnabled() {
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"})
resp.Forbidden(c, "gh-ost 功能已禁用,请在系统设置中开启")
return
}
@@ -321,13 +322,13 @@ func GhOstExecute(c *gin.Context) {
Force bool `json:"force"` // skip auto-trigger check
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
task.DryRun = false
@@ -408,7 +409,7 @@ func GhOstStatus(c *gin.Context) {
taskID := c.Param("taskId")
status, result := ghost.Manager.GetTaskStatus(taskID)
if status == "" {
c.JSON(http.StatusNotFound, gin.H{"detail": "Task not found"})
resp.NotFound(c, "Task not found")
return
}
c.JSON(http.StatusOK, gin.H{
@@ -445,7 +446,7 @@ func ListAnnouncements(c *gin.Context) {
// GET /api/v1/query-histories
func ListQueryHistories(c *gin.Context) {
_, _, _ = c.GetInt("user_id"), c.GetString("username"), c.GetString("role")
var histories []model.QueryHistory
model.DB.Order("id DESC").Limit(100).Find(&histories)
c.JSON(http.StatusOK, histories)
@@ -471,7 +472,7 @@ func UpdateSettings(c *gin.Context) {
Value string `json:"value"`
}
if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
for _, item := range items {
@@ -650,7 +651,7 @@ func SeedAuditRules(c *gin.Context) {
dbType := c.Param("dbType")
rules, ok := defaultAuditRules[dbType]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的数据库类型: %s", dbType)})
resp.BadRequest(c, fmt.Sprintf("不支持的数据库类型: %s", dbType))
return
}
@@ -679,7 +680,7 @@ func SeedAuditRules(c *gin.Context) {
Value: &value,
}
if err := model.DB.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("创建规则失败: %s", err.Error())})
resp.InternalErr(c, err)
return
}
count++
@@ -692,12 +693,12 @@ func SeedAuditRules(c *gin.Context) {
func ToggleAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
resp.BadRequest(c, "无效的规则 ID")
return
}
var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
resp.NotFound(c, "规则不存在")
return
}
newEnabled := !*rule.IsEnabled
@@ -709,12 +710,12 @@ func ToggleAuditRule(c *gin.Context) {
func UpdateAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
resp.BadRequest(c, "无效的规则 ID")
return
}
var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
resp.NotFound(c, "规则不存在")
return
}
var body struct {
@@ -724,7 +725,7 @@ func UpdateAuditRule(c *gin.Context) {
Severity *string `json:"severity"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
updates := map[string]interface{}{}
+16 -19
View File
@@ -5,8 +5,10 @@ import (
"strconv"
"strings"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"sql-platform/internal/safe"
"github.com/gin-gonic/gin"
)
@@ -35,14 +37,11 @@ func ListInstances(c *gin.Context) {
}
if err := query.Find(&instances).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
// mask passwords in response
for i := range instances {
instances[i].Password = ""
}
// 密码出站遮蔽由 Instance.MarshalJSON 统一收口
c.JSON(http.StatusOK, instances)
}
@@ -51,10 +50,9 @@ func GetInstance(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
instance.Password = "******"
c.JSON(http.StatusOK, instance)
}
@@ -62,7 +60,7 @@ func GetInstance(c *gin.Context) {
func CreateInstance(c *gin.Context) {
var instance model.Instance
if err := c.ShouldBindJSON(&instance); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
// Encrypt password before storing
@@ -74,11 +72,11 @@ func CreateInstance(c *gin.Context) {
}
}
if err := model.DB.Create(&instance).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
// Auto-detect version, cluster_mode and status after creation
go func() {
probe := func() {
result := testDBConnectionFull(&instance)
updates := map[string]interface{}{}
if result.Success {
@@ -95,8 +93,8 @@ func CreateInstance(c *gin.Context) {
if len(updates) > 0 {
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
}
}()
instance.Password = "******"
}
safe.Go("instance-probe:"+strconv.Itoa(instance.ID), probe)
c.JSON(http.StatusCreated, instance)
}
@@ -105,13 +103,13 @@ func UpdateInstance(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -130,12 +128,11 @@ func UpdateInstance(c *gin.Context) {
}
if err := model.DB.Model(&instance).Updates(input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
model.DB.First(&instance, id)
instance.Password = "******"
c.JSON(http.StatusOK, instance)
}
@@ -143,7 +140,7 @@ func UpdateInstance(c *gin.Context) {
func DeleteInstance(c *gin.Context) {
id := c.Param("id")
if err := model.DB.Delete(&model.Instance{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Instance deleted"})
@@ -154,7 +151,7 @@ func TestConnection(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -209,7 +206,7 @@ func InstanceMetrics(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
+41 -40
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"reflect"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"bytes"
@@ -28,7 +29,7 @@ func DetailListDatabases(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -39,14 +40,14 @@ func DetailListDatabases(c *gin.Context) {
db, err := getDBConnection(&instance, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
rows, err := db.Query("SHOW DATABASES")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer rows.Close()
@@ -72,7 +73,7 @@ func DetailListTables(c *gin.Context) {
dbName := c.Query("database")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -87,20 +88,20 @@ func DetailListTables(c *gin.Context) {
}
}
if dbName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"})
resp.BadRequest(c, "database parameter required")
return
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
rows, err := db.Query("SHOW TABLE STATUS")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer rows.Close()
@@ -139,7 +140,7 @@ func DetailDbSchema(c *gin.Context) {
dbName := c.Query("database")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -154,13 +155,13 @@ func DetailDbSchema(c *gin.Context) {
}
}
if dbName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"})
resp.BadRequest(c, "database parameter required")
return
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
@@ -168,7 +169,7 @@ func DetailDbSchema(c *gin.Context) {
// Get all tables and their columns
tableRows, err := db.Query("SHOW TABLES")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer tableRows.Close()
@@ -207,7 +208,7 @@ func DetailTableDetail(c *gin.Context) {
tableName := c.Query("table")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -217,13 +218,13 @@ func DetailTableDetail(c *gin.Context) {
}
}
if dbName == "" || tableName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "database and table parameters required"})
resp.BadRequest(c, "database and table parameters required")
return
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
@@ -235,7 +236,7 @@ func DetailTableDetail(c *gin.Context) {
// Get columns
colRows, err := db.Query("SHOW FULL COLUMNS FROM `" + tableName + "`")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer colRows.Close()
@@ -306,7 +307,7 @@ func DetailProcesslist(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -321,14 +322,14 @@ func DetailProcesslist(c *gin.Context) {
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
rows, err := db.Query("SHOW PROCESSLIST")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer rows.Close()
@@ -365,7 +366,7 @@ func DetailStatus(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -383,14 +384,14 @@ func DetailStatus(c *gin.Context) {
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
rows, err := db.Query("SHOW GLOBAL STATUS")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer rows.Close()
@@ -429,7 +430,7 @@ func DetailKillProcess(c *gin.Context) {
pid := c.Param("pid")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -439,14 +440,14 @@ func DetailKillProcess(c *gin.Context) {
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
_, err = db.Exec("KILL " + pid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Kill failed: %v", err)})
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"})
@@ -457,7 +458,7 @@ func DetailListUsers(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -472,7 +473,7 @@ func DetailListUsers(c *gin.Context) {
}
db, err := getDBConnection(&instance, dbName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
@@ -514,11 +515,11 @@ func ExecuteQuery(c *gin.Context) {
Offset int `json:"offset"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
if input.InstanceID == 0 || input.SQL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
resp.BadRequest(c, "instance_id and sql are required")
return
}
// Read query limits from platform settings
@@ -538,7 +539,7 @@ func ExecuteQuery(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -607,7 +608,7 @@ func ExecuteQuery(c *gin.Context) {
c.JSON(http.StatusOK, result)
default:
c.JSON(http.StatusBadRequest, gin.H{"detail": "Unsupported database type: " + instance.DBType})
resp.BadRequest(c, "Unsupported database type: "+instance.DBType)
}
}
@@ -1247,13 +1248,13 @@ func executeMongoQuery(instance *model.Instance, dbName string, queryStr string,
if pipeline != nil {
// Aggregation — add $facet for count + data
facetStage := bson.D{
{"$facet", bson.D{
{"data", bson.A{
bson.D{{"$skip", offset}},
bson.D{{"$limit", limit}},
{Key: "$facet", Value: bson.D{
{Key: "data", Value: bson.A{
bson.D{{Key: "$skip", Value: offset}},
bson.D{{Key: "$limit", Value: limit}},
}},
{"count", bson.A{
bson.D{{"$count", "total"}},
{Key: "count", Value: bson.A{
bson.D{{Key: "$count", Value: "total"}},
}},
}},
}
@@ -1404,11 +1405,11 @@ func ExportQueryCSV(c *gin.Context) {
Limit int `json:"limit"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
if input.InstanceID == 0 || input.SQL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
resp.BadRequest(c, "instance_id and sql are required")
return
}
if input.Limit <= 0 {
@@ -1420,7 +1421,7 @@ func ExportQueryCSV(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
@@ -1438,7 +1439,7 @@ func ExportQueryCSV(c *gin.Context) {
case "mongodb", "mongo":
exportMongoCSV(c, &instance, input.Database, input.SQL, input.Limit)
default:
c.JSON(http.StatusBadRequest, gin.H{"detail": "CSV export not supported for " + instance.DBType})
resp.BadRequest(c, "CSV export not supported for "+instance.DBType)
}
}
+4 -3
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -57,7 +58,7 @@ func ListDatabases(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
databases, err := listDatabases(&instance)
@@ -77,7 +78,7 @@ func ListTables(c *gin.Context) {
dbName := c.Param("db")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
tables, err := listTables(&instance, dbName)
@@ -102,7 +103,7 @@ func TestNewConnection(c *gin.Context) {
Database string `json:"database"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request"})
resp.BadRequest(c, "Invalid request")
return
}
+11 -10
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -17,7 +18,7 @@ func GetInstanceGroup(c *gin.Context) {
id := c.Param("id")
var group model.InstanceGroup
if err := model.DB.First(&group, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance group not found"})
resp.NotFound(c, "Instance group not found")
return
}
c.JSON(http.StatusOK, enrichGroup(group))
@@ -34,7 +35,7 @@ func CreateInstanceGroup(c *gin.Context) {
WriteInstanceIDs []int `json:"write_instance_ids"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -58,7 +59,7 @@ func CreateInstanceGroup(c *gin.Context) {
IsActive: &isActive,
}
if err := model.DB.Create(&group).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -83,7 +84,7 @@ func UpdateInstanceGroup(c *gin.Context) {
gid, _ := strconv.Atoi(id)
var group model.InstanceGroup
if err := model.DB.First(&group, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance group not found"})
resp.NotFound(c, "Instance group not found")
return
}
@@ -100,7 +101,7 @@ func UpdateInstanceGroup(c *gin.Context) {
WriteInstanceIDs *[]int `json:"write_instance_ids"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -132,7 +133,7 @@ func UpdateInstanceGroup(c *gin.Context) {
if len(updates) > 0 {
if err := model.DB.Model(&group).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
}
@@ -175,7 +176,7 @@ func UpdateInstanceGroup(c *gin.Context) {
func DeleteInstanceGroup(c *gin.Context) {
id := c.Param("id")
if err := model.DB.Delete(&model.InstanceGroup{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
// also clean up members
@@ -191,7 +192,7 @@ func AssignGroupInstances(c *gin.Context) {
AccessType string `json:"access_type"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
if input.AccessType == "" {
@@ -240,7 +241,7 @@ func AssignGroupUsers(c *gin.Context) {
Permissions []string `json:"permissions"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -278,7 +279,7 @@ func UpdateUserGroups(c *gin.Context) {
GroupIDs []int `json:"group_ids"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
+62 -38
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -46,7 +47,7 @@ func GetLDAPConfig(c *gin.Context) {
func UpdateLDAPConfig(c *gin.Context) {
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -84,11 +85,23 @@ func UpdateLDAPConfig(c *gin.Context) {
if len(configs) == 0 {
// Create new config
cfg := model.LDAPConfig{
LDAPHost: getStr("ldap_host"),
AdminDN: getStr("admin_dn"),
BaseDN: getStr("base_dn"),
SearchFilter: func() *string { s := getStr("search_filter"); if s != "" { return &s }; return nil }(),
UserMapping: func() *string { s := userMappingStr; if s != "" { return &s }; return nil }(),
LDAPHost: getStr("ldap_host"),
AdminDN: getStr("admin_dn"),
BaseDN: getStr("base_dn"),
SearchFilter: func() *string {
s := getStr("search_filter")
if s != "" {
return &s
}
return nil
}(),
UserMapping: func() *string {
s := userMappingStr
if s != "" {
return &s
}
return nil
}(),
}
if port := getInt("ldap_port"); port > 0 {
cfg.LDAPPort = &port
@@ -149,13 +162,13 @@ func TestLDAPConnection(c *gin.Context) {
TestPassword string `json:"test_password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "LDAP 配置不存在,请先保存配置"})
resp.NotFound(c, "LDAP 配置不存在,请先保存配置")
return
}
if cfg.LDAPHost == "" {
@@ -342,17 +355,17 @@ func ListLDAPUsers(c *gin.Context) {
}
type LdapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
}
var users []LdapUser
for _, e := range sr.Entries {
users = append(users, LdapUser{
DN: e.DN,
UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"),
DN: e.DN,
UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"),
Mail: e.GetAttributeValue("mail"),
})
}
@@ -367,26 +380,26 @@ func CreateLDAPOU(c *gin.Context) {
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.OuName == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 ou_name 参数"})
resp.BadRequest(c, "缺少 ou_name 参数")
return
}
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"})
resp.BadRequest(c, "LDAP 配置不存在")
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()})
resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return
}
defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"})
resp.BadRequest(c, "LDAP 管理员绑定失败")
return
}
@@ -399,7 +412,7 @@ func CreateLDAPOU(c *gin.Context) {
}
if err := conn.Add(addReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "创建 OU 失败: " + err.Error()})
resp.BadRequest(c, "创建 OU 失败: "+err.Error())
return
}
@@ -416,13 +429,13 @@ func CreateLDAPUser(c *gin.Context) {
OuDn string `json:"ou_dn"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 username 或 password 参数"})
resp.BadRequest(c, "缺少 username 或 password 参数")
return
}
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"})
resp.BadRequest(c, "LDAP 配置不存在")
return
}
@@ -434,14 +447,14 @@ func CreateLDAPUser(c *gin.Context) {
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()})
resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return
}
defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"})
resp.BadRequest(c, "LDAP 管理员绑定失败")
return
}
@@ -475,7 +488,7 @@ func CreateLDAPUser(c *gin.Context) {
}
if err := conn.Add(addReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "创建用户失败: " + err.Error()})
resp.BadRequest(c, "创建用户失败: "+err.Error())
return
}
@@ -496,20 +509,20 @@ func BatchImportLDAP(c *gin.Context) {
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"})
resp.BadRequest(c, "LDAP 配置不存在")
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()})
resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return
}
defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"})
resp.BadRequest(c, "LDAP 管理员绑定失败")
return
}
@@ -534,7 +547,7 @@ func BatchImportLDAP(c *gin.Context) {
[]string{"uid", "cn", "mail"}, nil,
))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 搜索失败: " + err.Error()})
resp.BadRequest(c, "LDAP 搜索失败: "+err.Error())
return
}
@@ -551,9 +564,14 @@ func BatchImportLDAP(c *gin.Context) {
if len(req.UserIDs) > 0 {
found := false
for _, id := range req.UserIDs {
if id == uid { found = true; break }
if id == uid {
found = true
break
}
}
if !found {
continue
}
if !found { continue }
}
cn := entry.GetAttributeValue("cn")
@@ -564,8 +582,12 @@ func BatchImportLDAP(c *gin.Context) {
if err == nil {
// Update existing user
updates := map[string]interface{}{}
if cn != "" && cn != existing.DisplayName { updates["display_name"] = cn }
if mail != "" && (existing.Email == nil || *existing.Email != mail) { updates["email"] = mail }
if cn != "" && cn != existing.DisplayName {
updates["display_name"] = cn
}
if mail != "" && (existing.Email == nil || *existing.Email != mail) {
updates["email"] = mail
}
if len(updates) > 0 {
model.DB.Model(&existing).Updates(updates)
}
@@ -582,7 +604,9 @@ func BatchImportLDAP(c *gin.Context) {
IsActive: &isActive,
HashedPassword: "-",
}
if mail != "" { newUser.Email = &mail }
if mail != "" {
newUser.Email = &mail
}
if err := model.DB.Create(&newUser).Error; err != nil {
results = append(results, map[string]string{"uid": uid, "status": "error: " + err.Error()})
skipped++
@@ -606,31 +630,31 @@ func DeleteLDAPUser(c *gin.Context) {
DN string `json:"dn" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 dn 参数"})
resp.BadRequest(c, "缺少 dn 参数")
return
}
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"})
resp.BadRequest(c, "LDAP 配置不存在")
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()})
resp.BadRequest(c, "LDAP 连接失败: "+err.Error())
return
}
defer conn.Close()
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"})
resp.BadRequest(c, "LDAP 管理员绑定失败")
return
}
if err := conn.Del(ldap.NewDelRequest(req.DN, nil)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "删除用户失败: " + err.Error()})
resp.BadRequest(c, "删除用户失败: "+err.Error())
return
}
+10 -9
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -63,11 +64,11 @@ func CreateNotification(c *gin.Context) {
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
resp.BadRequest(c, "参数错误")
return
}
if input.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "名称不能为空"})
resp.BadRequest(c, "名称不能为空")
return
}
cfgBytes, _ := json.Marshal(input.Config)
@@ -79,7 +80,7 @@ func CreateNotification(c *gin.Context) {
IsEnabled: &enabled,
}
if err := model.DB.Create(&ch).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"})
resp.InternalMsg(c, "创建失败")
return
}
c.JSON(http.StatusOK, toChannelResponse(ch))
@@ -90,12 +91,12 @@ func UpdateNotification(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
resp.NotFound(c, "通知渠道不存在")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
resp.BadRequest(c, "参数错误")
return
}
if name, ok := input["name"].(string); ok {
@@ -127,7 +128,7 @@ func TestNotificationSend(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
resp.NotFound(c, "通知渠道不存在")
return
}
@@ -135,7 +136,7 @@ func TestNotificationSend(c *gin.Context) {
_ = json.Unmarshal([]byte(ch.Config), &cfg)
webhookURL, _ := cfg["webhook_url"].(string)
if webhookURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Webhook URL 未配置"})
resp.BadRequest(c, "Webhook URL 未配置")
return
}
@@ -150,12 +151,12 @@ func TestNotificationSend(c *gin.Context) {
secret, _ := cfg["secret"].(string)
sendErr = sendDingtalkWebhook(webhookURL, secret, title, body)
default:
c.JSON(http.StatusBadRequest, gin.H{"detail": "不支持的通知类型: " + ch.ChannelType})
resp.BadRequest(c, "不支持的通知类型: "+ch.ChannelType)
return
}
if sendErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()})
resp.InternalErr(c, sendErr)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试消息已发送"})
+4 -3
View File
@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"net/http"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -14,7 +15,7 @@ func GetUserPreferences(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
resp.NotFound(c, "User not found")
return
}
@@ -31,7 +32,7 @@ func UpdateUserPreferences(c *gin.Context) {
var prefs map[string]string
if err := c.ShouldBindJSON(&prefs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -40,7 +41,7 @@ func UpdateUserPreferences(c *gin.Context) {
result := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("preferences", str)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": result.Error.Error()})
resp.InternalErr(c, result.Error)
return
}
+35 -34
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -384,13 +385,13 @@ func RedisInfo(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -398,7 +399,7 @@ func RedisInfo(c *gin.Context) {
ctx := context.Background()
info, err := client.Info(ctx).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -460,13 +461,13 @@ func RedisDbsize(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -474,7 +475,7 @@ func RedisDbsize(c *gin.Context) {
ctx := context.Background()
dbSize, err := client.DBSize(ctx).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"db_size": dbSize})
@@ -510,13 +511,13 @@ func RedisScanKeys(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -549,7 +550,7 @@ func RedisScanKeys(c *gin.Context) {
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -586,13 +587,13 @@ func RedisGetKeyDetail(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -636,13 +637,13 @@ func RedisSlowlog(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -650,7 +651,7 @@ func RedisSlowlog(c *gin.Context) {
ctx := context.Background()
logs, err := client.SlowLogGet(ctx, int64(count)).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -675,13 +676,13 @@ func RedisClients(c *gin.Context) {
id := c.Param("id")
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -689,7 +690,7 @@ func RedisClients(c *gin.Context) {
ctx := context.Background()
clientList, err := client.ClientList(ctx).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -729,19 +730,19 @@ func RedisExecuteCommand(c *gin.Context) {
Args []string `json:"args"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -760,7 +761,7 @@ func RedisExecuteCommand(c *gin.Context) {
}
if !readOnly[cmd] {
c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"})
resp.Forbidden(c, "Only read-only commands allowed via query interface")
return
}
@@ -803,19 +804,19 @@ func RedisCreateKey(c *gin.Context) {
TTL int `json:"ttl"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -823,7 +824,7 @@ func RedisCreateKey(c *gin.Context) {
ctx := context.Background()
if err := writeRedisKeyValue(ctx, client, input.Key, input.Type, input.Value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -850,30 +851,30 @@ func RedisUpdateKey(c *gin.Context) {
TTL int `json:"ttl"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
ctx := context.Background()
if err := client.Del(ctx, key).Err(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
if err := writeRedisKeyValue(ctx, client, key, input.Type, input.Value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
if input.TTL > 0 {
@@ -895,13 +896,13 @@ func RedisDeleteKey(c *gin.Context) {
var instance model.Instance
if err := model.DB.First(&instance, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
resp.NotFound(c, "Instance not found")
return
}
client, err := getRedisClient(&instance, db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
defer client.Close()
@@ -909,7 +910,7 @@ func RedisDeleteKey(c *gin.Context) {
ctx := context.Background()
deleted, err := client.Del(ctx, key).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Key deleted", "deleted": deleted})
@@ -920,7 +921,7 @@ func CloneWorkflow(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
resp.NotFound(c, "Workflow not found")
return
}
// clone
+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})
}
+7 -6
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -28,7 +29,7 @@ func ListScheduledTasks(c *gin.Context) {
"applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)",
userID, userID,
)
// admin sees everything
// admin sees everything
}
var total int64
@@ -40,8 +41,8 @@ func ListScheduledTasks(c *gin.Context) {
// Enrich with instance_name and instance_db_type
type ScheduledTicketItem struct {
model.Ticket
InstanceName string `json:"instance_name"`
InstanceDBType string `json:"instance_db_type"`
InstanceName string `json:"instance_name"`
InstanceDBType string `json:"instance_db_type"`
}
items := make([]ScheduledTicketItem, 0, len(tickets))
@@ -71,19 +72,19 @@ func CancelScheduledTask(c *gin.Context) {
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
// Only the applicant or admin/dba can cancel
if ticket.ApplicantID != userID && role != "admin" && role != "dba" {
c.JSON(http.StatusForbidden, gin.H{"detail": "Permission denied"})
resp.Forbidden(c, "Permission denied")
return
}
// Only allow cancellation of scheduled tickets
if !ticket.ScheduledAt.Valid {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket is not scheduled"})
resp.BadRequest(c, "Ticket is not scheduled")
return
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -20,7 +21,7 @@ func AuditTicketSQL(c *gin.Context) {
Database string `json:"database"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -184,7 +185,7 @@ func SQLFormat(c *gin.Context) {
SQL string `json:"sql" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -200,7 +201,7 @@ func SQLAnalyze(c *gin.Context) {
DBType string `json:"db_type"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
+139 -130
View File
@@ -11,6 +11,7 @@ import (
"time"
"sql-platform/internal/ghost"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -21,7 +22,7 @@ import (
// SqlExecItem records the execution result of a single SQL statement
type SqlExecItem struct {
SQL string `json:"sql"`
Status string `json:"status"` // success / fail
Status string `json:"status"` // success / fail
AffectedRows int64 `json:"affected_rows"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
@@ -39,15 +40,15 @@ type SqlExecResult struct {
// GET /api/v1/tickets/statistics
func TicketStatistics(c *gin.Context) {
userID, _, role := middleware.GetCurrentUser(c)
type statusCount struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
var counts []statusCount
query := model.DB.Model(&model.Ticket{}).Select("status, count(*) as count")
// Filter by user role
switch role {
case "developer":
@@ -58,11 +59,11 @@ func TicketStatistics(c *gin.Context) {
"applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)",
userID, userID,
)
// admin sees everything
// admin sees everything
}
query.Group("status").Scan(&counts)
stats := model.TicketStatistics{}
for _, c := range counts {
switch c.Status {
@@ -81,7 +82,7 @@ func TicketStatistics(c *gin.Context) {
}
}
stats.Total = stats.Pending + stats.Reviewing + stats.Executing + stats.Completed + stats.Failed + stats.Rejected
c.JSON(http.StatusOK, stats)
}
@@ -91,17 +92,21 @@ func ListTickets(c *gin.Context) {
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 100 { limit = 20 }
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
query := model.DB.Model(&model.Ticket{}).Order("id DESC")
// keyword search
keyword := c.Query("keyword")
if keyword != "" {
query = query.Where("title LIKE ? OR ticket_no LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
// Filter by user role
switch role {
case "admin":
@@ -112,24 +117,24 @@ func ListTickets(c *gin.Context) {
userID, userID,
)
}
if status != "" && status != "all" {
query = query.Where("status = ?", status)
}
var total int64
query.Count(&total)
var tickets []model.Ticket
query.Offset((page - 1) * limit).Limit(limit).Find(&tickets)
// Enrich tickets with has_audit_step info
type TicketWithMeta struct {
model.Ticket
HasAuditStep bool `json:"has_audit_step"`
HasExecuteStep bool `json:"has_execute_step"`
IsExecutor bool `json:"is_executor"`
IsApprover bool `json:"is_approver"`
HasAuditStep bool `json:"has_audit_step"`
HasExecuteStep bool `json:"has_execute_step"`
IsExecutor bool `json:"is_executor"`
IsApprover bool `json:"is_approver"`
}
var result []TicketWithMeta
for _, t := range tickets {
@@ -150,9 +155,9 @@ func ListTickets(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"items": result,
"total": total,
"page": page,
"items": result,
"total": total,
"page": page,
"page_size": limit,
})
}
@@ -162,10 +167,10 @@ func GetTicket(c *gin.Context) {
id := c.Param("id")
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
// Get approvals — enrich with user display names
var approvals []model.TicketApproval
model.DB.Where("ticket_id = ?", id).Order("step_number, id").Find(&approvals)
@@ -191,11 +196,11 @@ func GetTicket(c *gin.Context) {
}
approvalsResult = append(approvalsResult, item)
}
// Get SQL items
var sqlItems []model.TicketSQLItem
model.DB.Where("ticket_id = ?", id).Order("seq").Find(&sqlItems)
// Flatten ticket fields into response + add nested data
result := gin.H{}
// Marshal ticket to JSON then unmarshal to map for flattening
@@ -203,7 +208,7 @@ func GetTicket(c *gin.Context) {
json.Unmarshal(ticketBytes, &result)
result["approvals"] = approvalsResult
result["sql_items"] = sqlItems
// Add applicant_name and instance_name for frontend
var applicant model.User
if err := model.DB.First(&applicant, ticket.ApplicantID).Error; err == nil {
@@ -213,7 +218,7 @@ func GetTicket(c *gin.Context) {
if err := model.DB.First(&inst, ticket.InstanceID).Error; err == nil {
result["instance_name"] = inst.Name
}
c.JSON(http.StatusOK, result)
}
@@ -223,7 +228,7 @@ func CreateTicket(c *gin.Context) {
// auditor cannot create tickets
if userRole == "auditor" {
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审计员不能提交工单"})
resp.Forbidden(c, "权限不足:审计员不能提交工单")
return
}
@@ -240,9 +245,9 @@ func CreateTicket(c *gin.Context) {
RiskLevel *string `json:"risk_level"`
ExecStrategy *string `json:"exec_strategy"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -252,7 +257,7 @@ func CreateTicket(c *gin.Context) {
readOnlyPrefixes := []string{"SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"}
for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(upperSQL, prefix) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能"})
resp.BadRequest(c, "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能")
return
}
}
@@ -267,7 +272,7 @@ func CreateTicket(c *gin.Context) {
}
for _, ro := range redisReadOnlyCmds {
if cmdUpper == ro {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper)})
resp.BadRequest(c, fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper))
return
}
}
@@ -341,12 +346,12 @@ func CreateTicket(c *gin.Context) {
input.WorkflowID = inst.WorkflowID
}
}
status := "pending"
priority := "medium"
riskLevel := "low"
execStrategy := "stop_on_error"
if input.Priority != nil {
priority = *input.Priority
}
@@ -356,11 +361,11 @@ func CreateTicket(c *gin.Context) {
if input.ExecStrategy != nil {
execStrategy = *input.ExecStrategy
}
// Generate ticket number
now := time.Now()
ticketNo := now.Format("20060102") + "-" + fmt.Sprintf("%04d", time.Now().UnixNano()%10000)
ticket := model.Ticket{
Title: input.Title,
DBType: input.DBType,
@@ -377,12 +382,12 @@ func CreateTicket(c *gin.Context) {
ExecStrategy: &execStrategy,
TicketNo: &ticketNo,
}
if err := model.DB.Create(&ticket).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
// Create approval records based on workflow or default to admin
hasAuditStep := false
if input.WorkflowID != nil && *input.WorkflowID > 0 {
@@ -408,11 +413,11 @@ func CreateTicket(c *gin.Context) {
stepName := ws.Name
stepType := ws.StepType
approval := model.TicketApproval{
TicketID: ticket.ID,
StepNumber: ws.StepNumber,
StepName: &stepName,
StepType: &stepType,
ApproverID: &uidCopy,
TicketID: ticket.ID,
StepNumber: ws.StepNumber,
StepName: &stepName,
StepType: &stepType,
ApproverID: &uidCopy,
}
model.DB.Create(&approval)
}
@@ -476,13 +481,13 @@ func ApproveTicket(c *gin.Context) {
// Role check: admin/dba/developer can approve
if userRole != "admin" && userRole != "dba" && userRole != "developer" {
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:审核操作需要管理员、DBA 或开发者角色"})
resp.Forbidden(c, "权限不足:审核操作需要管理员、DBA 或开发者角色")
return
}
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
@@ -491,7 +496,7 @@ func ApproveTicket(c *gin.Context) {
Comment *string `json:"comment"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -501,7 +506,7 @@ func ApproveTicket(c *gin.Context) {
var approval model.TicketApproval
if err := model.DB.Where("ticket_id = ? AND approver_id = ? AND action IS NULL AND step_type != 'execute'", id, userID).First(&approval).Error; err != nil {
// Already approved or no pending approval for this user
c.JSON(http.StatusBadRequest, gin.H{"detail": "没有待处理的审批记录(你可能已经审批过了)"})
resp.BadRequest(c, "没有待处理的审批记录(你可能已经审批过了)")
return
}
@@ -510,7 +515,7 @@ func ApproveTicket(c *gin.Context) {
approval.ActualApproverID = &userID
approval.CompletedAt = &now
model.DB.Save(&approval)
// Update ticket status
newStatus := "approved"
if input.Action == "reject" {
@@ -524,11 +529,11 @@ func ApproveTicket(c *gin.Context) {
}
// If all audit steps done → "approved" (ready for execution)
}
ticket.Status = &newStatus
ticket.ReviewTime = &now
model.DB.Save(&ticket)
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "approval": approval})
}
@@ -539,19 +544,19 @@ func ExecuteTicket(c *gin.Context) {
// Role check: only admin/dba can execute
_, _, userRole := middleware.GetCurrentUser(c)
if userRole != "admin" && userRole != "dba" {
c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足:执行操作需要管理员或 DBA 角色"})
resp.Forbidden(c, "权限不足:执行操作需要管理员或 DBA 角色")
return
}
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
// Check ticket status — allow approved OR reviewing (will auto-approve execute step)
if ticket.Status != nil && *ticket.Status != "approved" && *ticket.Status != "reviewing" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"})
resp.BadRequest(c, "Ticket must be approved before execution")
return
}
@@ -564,7 +569,7 @@ func ExecuteTicket(c *gin.Context) {
Where("ticket_id = ? AND step_type = 'execute' AND (approver_id = ? OR approver_id IS NULL)", id, userID).
Count(&executorCheck)
if executorCheck == 0 {
c.JSON(http.StatusForbidden, gin.H{"detail": "你不是此工单的执行人"})
resp.Forbidden(c, "你不是此工单的执行人")
return
}
@@ -587,7 +592,7 @@ func ExecuteTicket(c *gin.Context) {
Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id).
Count(&pendingAudit)
if pendingAudit > 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "还有未完成的审核步骤"})
resp.BadRequest(c, "还有未完成的审核步骤")
return
}
@@ -597,27 +602,27 @@ func ExecuteTicket(c *gin.Context) {
Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id).
Count(&rejectedAudit)
if rejectedAudit > 0 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "审核已被拒绝,无法执行"})
resp.BadRequest(c, "审核已被拒绝,无法执行")
return
}
// Get instance info
var instance model.Instance
if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Instance not found"})
resp.BadRequest(c, "Instance not found")
return
}
status := "executing"
ticket.Status = &status
now := time.Now()
ticket.ExecuteTime = &now
model.DB.Save(&ticket)
// Execute SQL based on db_type
var execResult string
var execErr string
if ticket.DBType == "redis" {
// Execute Redis command (write operations allowed in ticket workflow)
redisDB := 0
@@ -716,31 +721,31 @@ func ExecuteTicket(c *gin.Context) {
execTimeout := getSettingInt("executor_timeout", 300)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second)
defer cancel()
execStart := time.Now()
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
stmtMs := time.Since(execStart).Milliseconds()
ghRes := SqlExecResult{
Status: "success",
TotalMs: stmtMs,
StartTime: execStart.UTC().Format(time.RFC3339),
EndTime: time.Now().UTC().Format(time.RFC3339),
}
if ghErr != nil {
ghRes.Status = "failed"
ghRes.Items = []SqlExecItem{{
SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs,
Error: fmt.Sprintf("%s\n%s", result.Error, ghErr.Error()),
}}
execErr = ghErr.Error()
} else {
ghRes.Items = []SqlExecItem{{
SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs,
Error: result.Output,
}}
}
if jsonBytes, jsonErr := json.Marshal(ghRes); jsonErr == nil {
execResult = string(jsonBytes)
}
execStart := time.Now()
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
stmtMs := time.Since(execStart).Milliseconds()
ghRes := SqlExecResult{
Status: "success",
TotalMs: stmtMs,
StartTime: execStart.UTC().Format(time.RFC3339),
EndTime: time.Now().UTC().Format(time.RFC3339),
}
if ghErr != nil {
ghRes.Status = "failed"
ghRes.Items = []SqlExecItem{{
SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs,
Error: fmt.Sprintf("%s\n%s", result.Error, ghErr.Error()),
}}
execErr = ghErr.Error()
} else {
ghRes.Items = []SqlExecItem{{
SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs,
Error: result.Output,
}}
}
if jsonBytes, jsonErr := json.Marshal(ghRes); jsonErr == nil {
execResult = string(jsonBytes)
}
// Skip normal execution path
goto executionDone
}
@@ -803,7 +808,7 @@ func ExecuteTicket(c *gin.Context) {
return
}
}
// Split multi-statement by semicolons — wrap in transaction for atomic rollback
// Split multi-statement by semicolons — wrap in transaction for atomic rollback
execStart := time.Now()
statements := splitSQL(ticket.SQLContent)
execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)}
@@ -853,8 +858,8 @@ func ExecuteTicket(c *gin.Context) {
}
}
}
executionDone:
executionDone:
// Update final status
if execErr != "" {
failStatus := "failed"
@@ -866,7 +871,7 @@ func ExecuteTicket(c *gin.Context) {
ticket.ExecuteResult = &execResult
}
model.DB.Save(&ticket)
c.JSON(http.StatusOK, gin.H{"message": "Execution completed", "ticket": ticket})
}
@@ -930,75 +935,74 @@ func parseAlterTable(sql string) (string, string, error) {
return tableName, alterClause, nil
}
// POST /api/v1/tickets/:id/cancel
func CancelTicket(c *gin.Context) {
id := c.Param("id")
userID, _, _ := middleware.GetCurrentUser(c)
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
// Only applicant or admin can cancel
if ticket.ApplicantID != userID {
_, _, role := middleware.GetCurrentUser(c)
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"detail": "Only applicant or admin can cancel"})
resp.Forbidden(c, "Only applicant or admin can cancel")
return
}
}
if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing" && *ticket.Status != "approved") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "只能撤销待审核、审核中或已通过(待执行)的工单"})
resp.BadRequest(c, "只能撤销待审核、审核中或已通过(待执行)的工单")
return
}
status := "cancelled"
ticket.Status = &status
now := time.Now()
ticket.ReviewTime = &now
model.DB.Save(&ticket)
c.JSON(http.StatusOK, gin.H{"message": "Ticket cancelled", "ticket": ticket})
}
// POST /api/v1/tickets/:id/rollback
func RollbackTicket(c *gin.Context) {
id := c.Param("id")
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
if ticket.RollbackSQL == nil || *ticket.RollbackSQL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "No rollback SQL available"})
resp.BadRequest(c, "No rollback SQL available")
return
}
// Get instance
var instance model.Instance
if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Instance not found"})
resp.BadRequest(c, "Instance not found")
return
}
if ticket.DBType == "redis" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Redis rollback not supported"})
resp.BadRequest(c, "Redis rollback not supported")
return
}
db, err := getDBConnection(&instance, ticket.TargetDatabase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
resp.InternalErr(c, err)
return
}
defer db.Close()
// Execute rollback SQL
statements := splitSQL(*ticket.RollbackSQL)
var results []string
@@ -1017,18 +1021,18 @@ func RollbackTicket(c *gin.Context) {
results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50)))
}
}
if execErr != "" {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Rollback failed", "error": execErr, "results": results})
return
}
status := "rollback"
ticket.Status = &status
execResult := strings.Join(results, "\n")
ticket.ExecuteResult = &execResult
model.DB.Save(&ticket)
c.JSON(http.StatusOK, gin.H{"message": "Rollback completed", "results": results})
}
@@ -1037,10 +1041,10 @@ func GetTicketTimeline(c *gin.Context) {
id := c.Param("id")
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
resp.NotFound(c, "Ticket not found")
return
}
type TimelineEntry struct {
ID int `json:"id"`
Action string `json:"action"`
@@ -1049,9 +1053,9 @@ func GetTicketTimeline(c *gin.Context) {
Timestamp string `json:"timestamp"`
Status string `json:"status"`
}
var timeline []TimelineEntry
// 1. Created event
timeline = append(timeline, TimelineEntry{
ID: 1,
@@ -1060,7 +1064,7 @@ func GetTicketTimeline(c *gin.Context) {
Timestamp: ticket.CreateTime.Format(time.RFC3339),
Status: "pending",
})
// 2. Approval events from ticket_approvals
var approvals []model.TicketApproval
model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals)
@@ -1082,7 +1086,7 @@ func GetTicketTimeline(c *gin.Context) {
Timestamp: ts,
})
}
// 3. Executed event
if ticket.ExecuteTime != nil {
execAction := "executed"
@@ -1095,36 +1099,41 @@ func GetTicketTimeline(c *gin.Context) {
ID: 99,
Action: execAction,
Timestamp: ticket.ExecuteTime.Format(time.RFC3339),
Status: func() string { if ticket.Status != nil { return *ticket.Status }; return "" }(),
Status: func() string {
if ticket.Status != nil {
return *ticket.Status
}
return ""
}(),
})
}
c.JSON(http.StatusOK, gin.H{"timeline": timeline})
}
// GET /api/v1/tickets/:id/approvals
func GetTicketApprovals(c *gin.Context) {
id := c.Param("id")
var approvals []model.TicketApproval
if err := model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
// Enrich with approver names
type ApprovalResponse struct {
model.TicketApproval
ApproverName string `json:"approver_name"`
}
var result []ApprovalResponse
for _, a := range approvals {
ar := ApprovalResponse{TicketApproval: a}
ar.ApproverName = getUserName(a.ApproverID)
result = append(result, ar)
}
c.JSON(http.StatusOK, result)
}
+22 -21
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"golang.org/x/crypto/bcrypt"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
@@ -15,7 +16,7 @@ func GetWorkflow(c *gin.Context) {
id := c.Param("id")
var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
resp.NotFound(c, "Workflow not found")
return
}
var steps []model.WorkflowStep
@@ -55,7 +56,7 @@ func CreateWorkflow(c *gin.Context) {
} `json:"steps"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -71,7 +72,7 @@ func CreateWorkflow(c *gin.Context) {
IsDefault: input.IsDefault,
}
if err := model.DB.Create(&wf).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -99,13 +100,13 @@ func UpdateWorkflow(c *gin.Context) {
id := c.Param("id")
var wf model.WorkflowDefinition
if err := model.DB.First(&wf, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
resp.NotFound(c, "Workflow not found")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -171,7 +172,7 @@ func ResetPassword(c *gin.Context) {
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
_, _ = id, input.NewPassword
@@ -182,18 +183,18 @@ func ResetPassword(c *gin.Context) {
// POST /api/v1/users/batch-import
func BatchImportUsers(c *gin.Context) {
var input struct {
Users []struct {
Users []struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Role string `json:"role"`
Password string `json:"password"`
} `json:"users"`
ImportType string `json:"import_type"`
SkipExisting bool `json:"skip_existing"`
ImportType string `json:"import_type"`
SkipExisting bool `json:"skip_existing"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -267,16 +268,16 @@ func BatchImportUsers(c *gin.Context) {
func BatchImportLdapUsers(c *gin.Context) {
var input struct {
Users []struct {
DN string `json:"dn"`
CN string `json:"cn"`
Nickname string `json:"nickname"`
UID string `json:"uid"`
Username string `json:"username"`
Mail string `json:"mail"`
DN string `json:"dn"`
CN string `json:"cn"`
Nickname string `json:"nickname"`
UID string `json:"uid"`
Username string `json:"username"`
Mail string `json:"mail"`
} `json:"users"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -359,21 +360,21 @@ func ResetUserPassword(c *gin.Context) {
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
id, err := strconv.Atoi(userId)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid user ID"})
resp.BadRequest(c, "Invalid user ID")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Hash failed"})
resp.InternalMsg(c, "Hash failed")
return
}
if err := model.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hash)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Update failed"})
resp.InternalMsg(c, "Update failed")
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
+75
View File
@@ -1,6 +1,9 @@
package middleware
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
@@ -13,6 +16,71 @@ import (
"github.com/golang-jwt/jwt/v5"
)
// RefreshTokenTTL refresh token 有效期;access token 有效期仍走 session_timeout_hours
const RefreshTokenTTL = 7 * 24 * time.Hour
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// GenerateRefreshToken 签发 refresh token(明文只返回给客户端,库里只存 SHA-256 摘要)
func GenerateRefreshToken(userID int) (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
token := hex.EncodeToString(buf)
rt := model.RefreshToken{
UserID: userID,
TokenHash: hashToken(token),
ExpiresAt: time.Now().Add(RefreshTokenTTL),
}
if err := model.DB.Create(&rt).Error; err != nil {
return "", err
}
// 顺手清理该用户已过期/已撤销的记录
model.DB.Where("user_id = ? AND (expires_at < ? OR revoked_at IS NOT NULL)", userID, time.Now()).Delete(&model.RefreshToken{})
return token, nil
}
// ConsumeRefreshToken 校验并轮换 refresh token(旧 token 撤销、签发新对),返回所属用户与新 refresh token。
func ConsumeRefreshToken(tokenStr string) (*model.User, string, error) {
var rt model.RefreshToken
if err := model.DB.Where("token_hash = ?", hashToken(tokenStr)).First(&rt).Error; err != nil {
return nil, "", err
}
if rt.RevokedAt != nil || rt.ExpiresAt.Before(time.Now()) {
return nil, "", ErrTokenRevoked
}
var user model.User
if err := model.DB.First(&user, rt.UserID).Error; err != nil {
return nil, "", err
}
if user.IsActive != nil && !*user.IsActive {
return nil, "", ErrTokenRevoked
}
now := time.Now()
rt.RevokedAt = &now
model.DB.Save(&rt)
newToken, err := GenerateRefreshToken(rt.UserID)
if err != nil {
return nil, "", err
}
return &user, newToken, nil
}
// RevokeUserRefreshTokens 撤销用户全部 refresh token(登出/改密时调用)
func RevokeUserRefreshTokens(userID int) {
now := time.Now()
model.DB.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked_at IS NULL", userID).
Update("revoked_at", now)
}
// getSessionTimeoutHours reads session_timeout_hours from system_settings,
// falls back to config/env, then default 24h.
func getSessionTimeoutHours() int {
@@ -35,6 +103,13 @@ type Claims struct {
jwt.RegisteredClaims
}
// ErrTokenRevoked refresh token 已撤销/过期
var ErrTokenRevoked = errTokenRevoked{}
type errTokenRevoked struct{}
func (errTokenRevoked) Error() string { return "refresh token revoked or expired" }
func GenerateToken(user *model.User) (string, error) {
claims := Claims{
UserID: user.ID,
+190 -165
View File
@@ -2,6 +2,7 @@ package model
import (
"database/sql"
"encoding/json"
"time"
"gorm.io/gorm"
@@ -10,21 +11,21 @@ import (
// ==================== User ====================
type User struct {
ID int `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:50;uniqueIndex;not null" json:"username"`
DisplayName string `gorm:"size:100;not null" json:"display_name"`
Email *string `gorm:"size:100" json:"email"`
Phone *string `gorm:"size:20" json:"phone"`
HashedPassword string `gorm:"size:200;not null" json:"-"`
AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap
Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor
IsActive *bool `gorm:"default:true" json:"is_active"`
Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"system","login_style":"split"}
LastLogin *time.Time `json:"last_login"`
FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"`
LockedUntil *time.Time `json:"locked_until"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
ID int `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:50;uniqueIndex;not null" json:"username"`
DisplayName string `gorm:"size:100;not null" json:"display_name"`
Email *string `gorm:"size:100" json:"email"`
Phone *string `gorm:"size:20" json:"phone"`
HashedPassword string `gorm:"size:200;not null" json:"-"`
AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap
Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor
IsActive *bool `gorm:"default:true" json:"is_active"`
Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"system","login_style":"split"}
LastLogin *time.Time `json:"last_login"`
FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"`
LockedUntil *time.Time `json:"locked_until"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
}
func (User) TableName() string { return "users" }
@@ -32,57 +33,66 @@ func (User) TableName() string { return "users" }
// ==================== Instance ====================
type Instance struct {
ID int `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
DBType string `gorm:"size:20;not null" json:"db_type"`
Host string `gorm:"size:200;not null" json:"host"`
Port int `gorm:"not null" json:"port"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:500;not null" json:"password,omitempty"`
Database *string `gorm:"size:100" json:"database"`
Env *string `gorm:"size:20" json:"env"`
Description *string `gorm:"type:text" json:"description"`
Status *string `gorm:"size:20" json:"status"`
Version *string `gorm:"size:50" json:"version"`
ClusterMode *bool `gorm:"default:false" json:"cluster_mode"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"`
Workflow *string `gorm:"type:text" json:"workflow"`
ExcludedDatabases *string `gorm:"type:text" json:"excluded_databases"`
MaskFields *string `gorm:"type:text" json:"mask_fields"`
GroupID *int `json:"group_id"`
WorkflowID *int `json:"workflow_id"`
ID int `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
DBType string `gorm:"size:20;not null" json:"db_type"`
Host string `gorm:"size:200;not null" json:"host"`
Port int `gorm:"not null" json:"port"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:500;not null" json:"password,omitempty"`
Database *string `gorm:"size:100" json:"database"`
Env *string `gorm:"size:20" json:"env"`
Description *string `gorm:"type:text" json:"description"`
Status *string `gorm:"size:20" json:"status"`
Version *string `gorm:"size:50" json:"version"`
ClusterMode *bool `gorm:"default:false" json:"cluster_mode"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"`
Workflow *string `gorm:"type:text" json:"workflow"`
ExcludedDatabases *string `gorm:"type:text" json:"excluded_databases"`
MaskFields *string `gorm:"type:text" json:"mask_fields"`
GroupID *int `json:"group_id"`
WorkflowID *int `json:"workflow_id"`
}
func (Instance) TableName() string { return "instances" }
// MarshalJSON 保证实例密文密码在任何响应中都不出站,
// 消除各 handler 手动遮蔽遗漏的隐患(入站绑定不受影响)。
func (i Instance) MarshalJSON() ([]byte, error) {
type instanceAlias Instance
a := instanceAlias(i)
a.Password = ""
return json.Marshal(a)
}
// ==================== Ticket ====================
type Ticket struct {
ID int `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200;not null" json:"title"`
DBType string `gorm:"size:20;not null" json:"db_type"`
InstanceID int `gorm:"not null" json:"instance_id"`
TargetDatabase string `gorm:"size:100;not null" json:"target_database"`
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
RollbackSQL *string `gorm:"type:text" json:"rollback_sql"`
Status *string `gorm:"size:20" json:"status"`
Priority *string `gorm:"size:10" json:"priority"`
ApplicantID int `gorm:"not null" json:"applicant_id"`
ApproverID *int `json:"approver_id"`
Reason *string `gorm:"type:text" json:"reason"`
AuditResult *string `gorm:"type:text" json:"audit_result"`
ExecuteResult *string `gorm:"type:text" json:"execute_result"`
CreateTime time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"create_time"`
ReviewTime *time.Time `json:"review_time"`
ExecuteTime *time.Time `json:"execute_time"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
TicketNo *string `gorm:"size:20" json:"ticket_no"`
ScheduledAt sql.NullTime `json:"scheduled_at"`
WorkflowID *int `json:"workflow_id"`
RiskLevel *string `gorm:"size:20;default:low" json:"risk_level"`
ExecStrategy *string `gorm:"size:20;default:stop_on_error" json:"exec_strategy"`
ID int `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200;not null" json:"title"`
DBType string `gorm:"size:20;not null" json:"db_type"`
InstanceID int `gorm:"not null" json:"instance_id"`
TargetDatabase string `gorm:"size:100;not null" json:"target_database"`
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
RollbackSQL *string `gorm:"type:text" json:"rollback_sql"`
Status *string `gorm:"size:20" json:"status"`
Priority *string `gorm:"size:10" json:"priority"`
ApplicantID int `gorm:"not null" json:"applicant_id"`
ApproverID *int `json:"approver_id"`
Reason *string `gorm:"type:text" json:"reason"`
AuditResult *string `gorm:"type:text" json:"audit_result"`
ExecuteResult *string `gorm:"type:text" json:"execute_result"`
CreateTime time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"create_time"`
ReviewTime *time.Time `json:"review_time"`
ExecuteTime *time.Time `json:"execute_time"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
TicketNo *string `gorm:"size:20" json:"ticket_no"`
ScheduledAt sql.NullTime `json:"scheduled_at"`
WorkflowID *int `json:"workflow_id"`
RiskLevel *string `gorm:"size:20;default:low" json:"risk_level"`
ExecStrategy *string `gorm:"size:20;default:stop_on_error" json:"exec_strategy"`
}
func (Ticket) TableName() string { return "tickets" }
@@ -90,24 +100,24 @@ func (Ticket) TableName() string { return "tickets" }
// ==================== TicketApproval ====================
type TicketApproval struct {
ID int `gorm:"primaryKey" json:"id"`
TicketID int `gorm:"not null" json:"ticket_id"`
WorkflowID *int `json:"workflow_id"`
VersionID *int `json:"version_id"`
StepNumber int `gorm:"not null" json:"step_number"`
StepName *string `gorm:"size:100" json:"step_name"`
StepType *string `gorm:"size:20" json:"step_type"`
ApproverID *int `json:"approver_id"`
ApproverRole *string `gorm:"size:20" json:"approver_role"`
ActualApproverID *int `json:"actual_approver_id"`
Action *string `gorm:"size:20" json:"action"`
Comment *string `gorm:"type:text" json:"comment"`
TransferFrom *int `json:"transfer_from"`
TransferReason *string `gorm:"type:text" json:"transfer_reason"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
CompletedAt *time.Time `json:"completed_at"`
IsTimeout *bool `json:"is_timeout"`
ID int `gorm:"primaryKey" json:"id"`
TicketID int `gorm:"not null" json:"ticket_id"`
WorkflowID *int `json:"workflow_id"`
VersionID *int `json:"version_id"`
StepNumber int `gorm:"not null" json:"step_number"`
StepName *string `gorm:"size:100" json:"step_name"`
StepType *string `gorm:"size:20" json:"step_type"`
ApproverID *int `json:"approver_id"`
ApproverRole *string `gorm:"size:20" json:"approver_role"`
ActualApproverID *int `json:"actual_approver_id"`
Action *string `gorm:"size:20" json:"action"`
Comment *string `gorm:"type:text" json:"comment"`
TransferFrom *int `json:"transfer_from"`
TransferReason *string `gorm:"type:text" json:"transfer_reason"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
CompletedAt *time.Time `json:"completed_at"`
IsTimeout *bool `json:"is_timeout"`
TimeoutHandledAt *time.Time `json:"timeout_handled_at"`
}
@@ -116,18 +126,18 @@ func (TicketApproval) TableName() string { return "ticket_approvals" }
// ==================== TicketSQLItem ====================
type TicketSQLItem struct {
ID int `gorm:"primaryKey" json:"id"`
TicketID int `gorm:"not null" json:"ticket_id"`
Seq int `gorm:"not null" json:"seq"`
SQLText string `gorm:"type:text;not null" json:"sql_text"`
SQLType *string `gorm:"size:20" json:"sql_type"`
Status *string `gorm:"size:20;default:pending" json:"status"`
AffectedRows *int `gorm:"default:0" json:"affected_rows"`
DurationMs *int `gorm:"default:0" json:"duration_ms"`
ErrorMessage *string `gorm:"type:text" json:"error_message"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
ID int `gorm:"primaryKey" json:"id"`
TicketID int `gorm:"not null" json:"ticket_id"`
Seq int `gorm:"not null" json:"seq"`
SQLText string `gorm:"type:text;not null" json:"sql_text"`
SQLType *string `gorm:"size:20" json:"sql_type"`
Status *string `gorm:"size:20;default:pending" json:"status"`
AffectedRows *int `gorm:"default:0" json:"affected_rows"`
DurationMs *int `gorm:"default:0" json:"duration_ms"`
ErrorMessage *string `gorm:"type:text" json:"error_message"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
}
func (TicketSQLItem) TableName() string { return "ticket_sql_items" }
@@ -204,37 +214,37 @@ func (QueryHistory) TableName() string { return "query_histories" }
// ==================== Workflow ====================
type WorkflowDefinition struct {
ID int `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Description *string `gorm:"type:text" json:"description"`
Category *string `gorm:"size:50" json:"category"`
IsDefault *bool `json:"is_default"`
IsActive *bool `json:"is_active"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
CreatedBy *int `json:"created_by"`
FlowConfig *string `gorm:"type:json" json:"flow_config"`
TriggerConditions *string `gorm:"type:json" json:"trigger_conditions"`
ID int `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Description *string `gorm:"type:text" json:"description"`
Category *string `gorm:"size:50" json:"category"`
IsDefault *bool `json:"is_default"`
IsActive *bool `json:"is_active"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
CreatedBy *int `json:"created_by"`
FlowConfig *string `gorm:"type:json" json:"flow_config"`
TriggerConditions *string `gorm:"type:json" json:"trigger_conditions"`
}
func (WorkflowDefinition) TableName() string { return "workflow_definitions" }
type WorkflowStep struct {
ID int `gorm:"primaryKey" json:"id"`
WorkflowID int `gorm:"not null" json:"workflow_id"`
StepNumber int `gorm:"not null" json:"step_number"`
Name string `gorm:"size:100;not null" json:"name"`
Description *string `gorm:"type:text" json:"description"`
StepType string `gorm:"size:20;not null" json:"step_type"`
ApproverRole *string `gorm:"size:20" json:"approver_role"`
ApproverIDs *string `gorm:"type:json" json:"approver_ids"`
ApproverAssignment *string `gorm:"type:json" json:"approver_assignment"`
IsRequired *bool `json:"is_required"`
TimeoutHours *int `json:"timeout_hours"`
AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"`
EntryConditions *string `gorm:"type:json" json:"entry_conditions"`
TransitionConditions *string `gorm:"type:json" json:"transition_conditions"`
FieldPermissions *string `gorm:"type:json" json:"field_permissions"`
ID int `gorm:"primaryKey" json:"id"`
WorkflowID int `gorm:"not null" json:"workflow_id"`
StepNumber int `gorm:"not null" json:"step_number"`
Name string `gorm:"size:100;not null" json:"name"`
Description *string `gorm:"type:text" json:"description"`
StepType string `gorm:"size:20;not null" json:"step_type"`
ApproverRole *string `gorm:"size:20" json:"approver_role"`
ApproverIDs *string `gorm:"type:json" json:"approver_ids"`
ApproverAssignment *string `gorm:"type:json" json:"approver_assignment"`
IsRequired *bool `json:"is_required"`
TimeoutHours *int `json:"timeout_hours"`
AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"`
EntryConditions *string `gorm:"type:json" json:"entry_conditions"`
TransitionConditions *string `gorm:"type:json" json:"transition_conditions"`
FieldPermissions *string `gorm:"type:json" json:"field_permissions"`
}
func (WorkflowStep) TableName() string { return "workflow_steps" }
@@ -359,15 +369,15 @@ func (NotificationTemplate) TableName() string { return "notification_templates"
// ==================== Other Models ====================
type SystemSetting struct {
ID int `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:100;not null" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
ValueType string `gorm:"size:20;not null" json:"value_type"`
Group string `gorm:"size:50;not null" json:"group"`
Label string `gorm:"size:100;not null" json:"label"`
Desc *string `gorm:"type:text" json:"description"`
DefaultValue *string `gorm:"type:text" json:"default_value"`
SortOrder int `gorm:"not null" json:"sort_order"`
ID int `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:100;not null" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
ValueType string `gorm:"size:20;not null" json:"value_type"`
Group string `gorm:"size:50;not null" json:"group"`
Label string `gorm:"size:100;not null" json:"label"`
Desc *string `gorm:"type:text" json:"description"`
DefaultValue *string `gorm:"type:text" json:"default_value"`
SortOrder int `gorm:"not null" json:"sort_order"`
UpdatedAt *time.Time `json:"updated_at"`
}
@@ -420,15 +430,15 @@ type Backup struct {
func (Backup) TableName() string { return "backups" }
type SchemaRevision struct {
ID int `gorm:"primaryKey" json:"id"`
InstanceID int `gorm:"not null" json:"instance_id"`
Database string `gorm:"size:100;not null" json:"database"`
Revision string `gorm:"size:64;not null" json:"revision"`
DDLContent *string `gorm:"type:text" json:"ddl_content"`
Checksum string `gorm:"size:64;not null" json:"checksum"`
AppliedBy *int `json:"applied_by"`
AppliedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"applied_at"`
Status *string `gorm:"size:20" json:"status"`
ID int `gorm:"primaryKey" json:"id"`
InstanceID int `gorm:"not null" json:"instance_id"`
Database string `gorm:"size:100;not null" json:"database"`
Revision string `gorm:"size:64;not null" json:"revision"`
DDLContent *string `gorm:"type:text" json:"ddl_content"`
Checksum string `gorm:"size:64;not null" json:"checksum"`
AppliedBy *int `json:"applied_by"`
AppliedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"applied_at"`
Status *string `gorm:"size:20" json:"status"`
}
func (SchemaRevision) TableName() string { return "schema_revisions" }
@@ -450,50 +460,64 @@ type SlowLog struct {
func (SlowLog) TableName() string { return "slow_logs" }
type ScheduledTask struct {
ID int `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Description *string `gorm:"type:text" json:"description"`
TaskSQLType string `gorm:"size:20;not null" json:"task_sql_type"`
InstanceID int `gorm:"not null" json:"instance_id"`
TargetDB *string `gorm:"size:100" json:"target_db"`
TargetTable *string `gorm:"size:100" json:"target_table"`
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
CronExpression string `gorm:"size:50;not null" json:"cron_expression"`
IsEnabled *bool `json:"is_enabled"`
LastRunAt *time.Time `json:"last_run_at"`
LastRunStatus *string `gorm:"size:20" json:"last_run_status"`
LastRunMessage *string `gorm:"type:text" json:"last_run_message"`
NextRunAt *time.Time `json:"next_run_at"`
RunCount *int `json:"run_count"`
CreatedBy *int `json:"created_by"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
ID int `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Description *string `gorm:"type:text" json:"description"`
TaskSQLType string `gorm:"size:20;not null" json:"task_sql_type"`
InstanceID int `gorm:"not null" json:"instance_id"`
TargetDB *string `gorm:"size:100" json:"target_db"`
TargetTable *string `gorm:"size:100" json:"target_table"`
SQLContent string `gorm:"type:text;not null" json:"sql_content"`
CronExpression string `gorm:"size:50;not null" json:"cron_expression"`
IsEnabled *bool `json:"is_enabled"`
LastRunAt *time.Time `json:"last_run_at"`
LastRunStatus *string `gorm:"size:20" json:"last_run_status"`
LastRunMessage *string `gorm:"type:text" json:"last_run_message"`
NextRunAt *time.Time `json:"next_run_at"`
RunCount *int `json:"run_count"`
CreatedBy *int `json:"created_by"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
}
func (ScheduledTask) TableName() string { return "scheduled_tasks" }
type ScheduledTaskLog struct {
ID int `gorm:"primaryKey" json:"id"`
TaskID int `gorm:"not null" json:"task_id"`
Status string `gorm:"size:20;not null" json:"status"`
SQLContent *string `gorm:"type:text" json:"sql_content"`
AffectedRows *int `json:"affected_rows"`
DurationMs *int `json:"duration_ms"`
ErrorMessage *string `gorm:"type:text" json:"error_message"`
StartedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
ID int `gorm:"primaryKey" json:"id"`
TaskID int `gorm:"not null" json:"task_id"`
Status string `gorm:"size:20;not null" json:"status"`
SQLContent *string `gorm:"type:text" json:"sql_content"`
AffectedRows *int `json:"affected_rows"`
DurationMs *int `json:"duration_ms"`
ErrorMessage *string `gorm:"type:text" json:"error_message"`
StartedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
func (ScheduledTaskLog) TableName() string { return "scheduled_task_logs" }
type UserGroup struct {
UserID int `gorm:"primaryKey" json:"user_id"`
GroupID int `gorm:"primaryKey" json:"group_id"`
UserID int `gorm:"primaryKey" json:"user_id"`
GroupID int `gorm:"primaryKey" json:"group_id"`
Permissions *string `gorm:"type:text" json:"permissions"`
}
func (UserGroup) TableName() string { return "user_groups" }
// ==================== RefreshToken ====================
// RefreshToken 持久化 refresh token 的 SHA-256 摘要(明文不落库),支持过期与撤销。
type RefreshToken struct {
ID int `gorm:"primaryKey" json:"id"`
UserID int `gorm:"index;not null" json:"user_id"`
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
}
func (RefreshToken) TableName() string { return "refresh_tokens" }
// ==================== DTO ====================
type LoginRequest struct {
@@ -502,9 +526,10 @@ type LoginRequest struct {
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
User User `json:"user"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
User User `json:"user"`
}
type TicketStatistics struct {
+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
api.POST("/auth/login", handler.Login)
api.POST("/auth/refresh", handler.RefreshToken)
api.GET("/public/login-style", handler.GetPublicLoginStyle)
// Authenticated routes
@@ -20,6 +21,7 @@ func Setup(r *gin.Engine) {
{
// Auth
auth.GET("/auth/me", handler.GetMe)
auth.POST("/auth/logout", handler.Logout)
// Dashboard
auth.GET("/dashboard/stats", handler.DashboardStats)