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:
1 parent
d5b8b8116c
commit
16f1f7d65f
26 files changed
+916
-963
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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{}{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "测试消息已发送"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package resp 统一 API 响应助手(渐进式契约统一):
|
||||
// - 成功:直接返回业务数据;分页统一 {items, total, page, page_size} 信封
|
||||
// - 失败:HTTP 状态码语义保持不变,错误体 {detail, code?}(兼容既有前端与测试)
|
||||
// - 内部错误:服务端记录完整 error,客户端只收通用文案,杜绝 DB 错误原文泄漏
|
||||
//
|
||||
// 新代码禁止直接 c.JSON 错误响应;存量调用点在迭代中逐步迁移到本包。
|
||||
package resp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OK 200:直接返回业务数据
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// Created 201:资源创建成功
|
||||
func Created(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusCreated, data)
|
||||
}
|
||||
|
||||
// Page 200:统一分页信封 {items, total, page, page_size}
|
||||
func Page(c *gin.Context, items any, total int64, page, pageSize int) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Message 200:仅含 {message} 的轻量确认(删除/测试类端点)
|
||||
func Message(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
// Fail 通用失败:{detail}
|
||||
func Fail(c *gin.Context, status int, detail string) {
|
||||
c.JSON(status, gin.H{"detail": detail})
|
||||
}
|
||||
|
||||
// FailCode 失败并携带机器可读错误码:{detail, code}
|
||||
func FailCode(c *gin.Context, status int, code, detail string) {
|
||||
c.JSON(status, gin.H{"detail": detail, "code": code})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, detail string) { Fail(c, http.StatusBadRequest, detail) }
|
||||
func Unauthorized(c *gin.Context, detail string) { Fail(c, http.StatusUnauthorized, detail) }
|
||||
func Forbidden(c *gin.Context, detail string) { Fail(c, http.StatusForbidden, detail) }
|
||||
func NotFound(c *gin.Context, detail string) { Fail(c, http.StatusNotFound, detail) }
|
||||
|
||||
// InternalErr 500:服务端 log 完整错误,客户端只收通用文案(防 DB/内部错误原文泄漏)
|
||||
func InternalErr(c *gin.Context, err error) {
|
||||
log.Printf("[resp] internal error on %s %s: %v", c.Request.Method, c.Request.URL.Path, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "服务器内部错误,请稍后重试", "code": "internal"})
|
||||
}
|
||||
|
||||
// InternalMsg 500:无底层 error、需要给用户可读文案时使用
|
||||
func InternalMsg(c *gin.Context, userMsg string) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": userMsg})
|
||||
}
|
||||
|
||||
// TooManyRequests 429:携带 retry_after_minutes
|
||||
func TooManyRequests(c *gin.Context, detail string, retryAfterMinutes int) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": detail, "retry_after_minutes": retryAfterMinutes})
|
||||
}
|
||||
@@ -4,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user