From f2e93a09c059c097db8e311595531bf68b7f3a91 Mon Sep 17 00:00:00 2001 From: logan Date: Tue, 26 May 2026 20:40:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=86=E8=8A=82=E6=89=93=E7=A3=A8=20?= =?UTF-8?q?-=20=E5=85=A8=E9=9D=A2=E4=BF=AE=E5=A4=8D=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端修复: - ticket: 添加独立 CancelTicket/RollbackTicket/GetTicketTimeline/GetTicketApprovals handler (之前 cancel/rollback 都错误调用 ApproveTicket, timeline/approvals 调用 GetTicket) - instance_detail: 7个 TODO placeholder 端点全部实现真实 MySQL 查询 (databases/tables/db-schema/table-detail/processlist/status/users) - instance_detail: ExecuteQuery 添加真实 Redis 命令执行(只读命令白名单) - redis: 全部 handler 从 placeholder 改为真实 go-redis 实现 (info/dbsize/keys/key-detail/slowlog/clients/command/create-key/delete-key) - router: 添加 sql-assistant/format, sql-assistant/analyze, tickets/audit 路由 - router: 添加 dictionary/*, mongo/* stub 路由(前端不报 404) - stub_handlers: 新增 SQL 审计/格式化/分析 handler + dictionary/mongo stub - model: TicketStatistics.Completed JSON tag 改为 success 匹配前端 - ticket: ExecuteTicket 完成 status 改为 success 而非 completed 前端兼容: - 所有前端 API 路径后端都有对应路由(无 404) - TicketStatistics 返回 success 字段与前端 stats?.success 对齐 --- backend/internal/handler/instance_detail.go | 603 +++++++++++++++++++- backend/internal/handler/redis.go | 423 ++++++++++++-- backend/internal/handler/stub_handlers.go | 274 +++++++++ backend/internal/handler/ticket.go | 316 +++++++++- backend/router/router.go | 28 + 5 files changed, 1577 insertions(+), 67 deletions(-) create mode 100644 backend/internal/handler/stub_handlers.go diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 8b749f7..b3715b7 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -1,11 +1,17 @@ package handler import ( + "fmt" "net/http" "sql-platform/internal/model" "github.com/gin-gonic/gin" + "time" + "context" + "github.com/redis/go-redis/v9" + "sql-platform/internal/middleware" + "strings" ) // GET /api/v1/instances/:id/detail/databases @@ -16,8 +22,39 @@ func DetailListDatabases(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SHOW DATABASES - c.JSON(http.StatusOK, []string{"information_schema"}) + + if instance.DBType == "redis" { + c.JSON(http.StatusOK, []string{}) + return + } + + db, err := getDBConnection(&instance, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + return + } + defer db.Close() + + rows, err := db.Query("SHOW DATABASES") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer rows.Close() + + var databases []string + for rows.Next() { + var name string + rows.Scan(&name) + // Filter out system databases + if name != "information_schema" && name != "performance_schema" && name != "mysql" && name != "sys" { + databases = append(databases, name) + } + } + if databases == nil { + databases = []string{} + } + c.JSON(http.StatusOK, databases) } // GET /api/v1/instances/:id/detail/tables @@ -29,9 +66,61 @@ func DetailListTables(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SHOW TABLES from database - _ = dbName - c.JSON(http.StatusOK, []interface{}{}) + + if instance.DBType == "redis" { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + if dbName == "" { + if instance.Database != nil { + dbName = *instance.Database + } + } + if dbName == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "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)}) + return + } + defer db.Close() + + rows, err := db.Query("SHOW TABLE STATUS") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer rows.Close() + + cols, _ := rows.Columns() + var tables []map[string]interface{} + for rows.Next() { + values := make([]interface{}, len(cols)) + ptrValues := make([]interface{}, len(cols)) + for i := range values { + ptrValues[i] = &values[i] + } + rows.Scan(ptrValues...) + rowMap := make(map[string]interface{}) + for i, col := range cols { + val := values[i] + b, ok := val.([]byte) + if ok { + rowMap[col] = string(b) + } else { + rowMap[col] = val + } + } + tables = append(tables, rowMap) + } + if tables == nil { + tables = []map[string]interface{}{} + } + c.JSON(http.StatusOK, tables) } // GET /api/v1/instances/:id/detail/db-schema @@ -43,9 +132,62 @@ func DetailDbSchema(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual schema introspection - _ = dbName - c.JSON(http.StatusOK, map[string]interface{}{}) + + if instance.DBType == "redis" { + c.JSON(http.StatusOK, map[string]interface{}{}) + return + } + + if dbName == "" { + if instance.Database != nil { + dbName = *instance.Database + } + } + if dbName == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "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)}) + return + } + defer db.Close() + + // Get all tables and their columns + tableRows, err := db.Query("SHOW TABLES") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer tableRows.Close() + + schema := make(map[string][]string) + for tableRows.Next() { + var tableName string + tableRows.Scan(&tableName) + // Get columns for this table + colRows, err := db.Query("SHOW COLUMNS FROM " + "`" + tableName + "`") + if err != nil { + schema[tableName] = []string{} + continue + } + var cols []string + for colRows.Next() { + var field, typ, null, key string + var extra interface{} + var def interface{} + colRows.Scan(&field, &typ, &null, &key, &def, &extra) + cols = append(cols, field+" ("+typ+")") + } + colRows.Close() + if cols == nil { + cols = []string{} + } + schema[tableName] = cols + } + c.JSON(http.StatusOK, schema) } // GET /api/v1/instances/:id/detail/table-detail @@ -58,13 +200,95 @@ func DetailTableDetail(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SHOW CREATE TABLE + SHOW COLUMNS + SHOW INDEX - _, _ = dbName, tableName - c.JSON(http.StatusOK, gin.H{ - "ddl": "", - "columns": []interface{}{}, - "indexes": []interface{}{}, - }) + + if dbName == "" { + if instance.Database != nil { + dbName = *instance.Database + } + } + if dbName == "" || tableName == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "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)}) + return + } + defer db.Close() + + // Get DDL + var ddl string + db.QueryRow("SHOW CREATE TABLE `"+tableName+"`").Scan(&tableName, &ddl) + + // Get columns + colRows, err := db.Query("SHOW FULL COLUMNS FROM `" + tableName + "`") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer colRows.Close() + + colCols, _ := colRows.Columns() + var columns []map[string]interface{} + for colRows.Next() { + values := make([]interface{}, len(colCols)) + ptrValues := make([]interface{}, len(colCols)) + for i := range values { + ptrValues[i] = &values[i] + } + colRows.Scan(ptrValues...) + rowMap := make(map[string]interface{}) + for i, col := range colCols { + val := values[i] + b, ok := val.([]byte) + if ok { + rowMap[col] = string(b) + } else { + rowMap[col] = val + } + } + columns = append(columns, rowMap) + } + + // Get indexes + idxRows, err := db.Query("SHOW INDEX FROM `" + tableName + "`") + if err == nil { + defer idxRows.Close() + idxCols, _ := idxRows.Columns() + var indexes []map[string]interface{} + for idxRows.Next() { + values := make([]interface{}, len(idxCols)) + ptrValues := make([]interface{}, len(idxCols)) + for i := range values { + ptrValues[i] = &values[i] + } + idxRows.Scan(ptrValues...) + rowMap := make(map[string]interface{}) + for i, col := range idxCols { + val := values[i] + b, ok := val.([]byte) + if ok { + rowMap[col] = string(b) + } else { + rowMap[col] = val + } + } + indexes = append(indexes, rowMap) + } + c.JSON(http.StatusOK, gin.H{ + "ddl": ddl, + "columns": columns, + "indexes": indexes, + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "ddl": ddl, + "columns": columns, + "indexes": []interface{}{}, + }) + } } // GET /api/v1/instances/:id/detail/processlist @@ -75,8 +299,55 @@ func DetailProcesslist(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SHOW PROCESSLIST - c.JSON(http.StatusOK, []interface{}{}) + + if instance.DBType == "redis" { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + dbName := "" + if instance.Database != nil { + dbName = *instance.Database + } + db, err := getDBConnection(&instance, dbName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + return + } + defer db.Close() + + rows, err := db.Query("SHOW PROCESSLIST") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer rows.Close() + + cols, _ := rows.Columns() + var processes []map[string]interface{} + for rows.Next() { + values := make([]interface{}, len(cols)) + ptrValues := make([]interface{}, len(cols)) + for i := range values { + ptrValues[i] = &values[i] + } + rows.Scan(ptrValues...) + rowMap := make(map[string]interface{}) + for i, col := range cols { + val := values[i] + b, ok := val.([]byte) + if ok { + rowMap[col] = string(b) + } else { + rowMap[col] = val + } + } + processes = append(processes, rowMap) + } + if processes == nil { + processes = []map[string]interface{}{} + } + c.JSON(http.StatusOK, processes) } // GET /api/v1/instances/:id/detail/status @@ -87,16 +358,88 @@ func DetailStatus(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SHOW GLOBAL STATUS + + if instance.DBType == "redis" { + c.JSON(http.StatusOK, gin.H{ + "key_metrics": map[string]string{}, + "all_status": []interface{}{}, + }) + return + } + + dbName := "" + if instance.Database != nil { + dbName = *instance.Database + } + db, err := getDBConnection(&instance, dbName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + return + } + defer db.Close() + + rows, err := db.Query("SHOW GLOBAL STATUS") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer rows.Close() + + keyMetrics := make(map[string]string) + var allStatus []map[string]interface{} + importantKeys := map[string]bool{ + "Threads_connected": true, "Threads_running": true, "Threads_cached": true, + "Queries": true, "Questions": true, "Slow_queries": true, + "Connections": true, "Uptime": true, + "Bytes_received": true, "Bytes_sent": true, + "Table_locks_waited": true, "Table_locks_immediate": true, + "Innodb_row_lock_waits": true, "Innodb_buffer_pool_reads": true, + } + + for rows.Next() { + var name, val string + rows.Scan(&name, &val) + allStatus = append(allStatus, map[string]interface{}{"name": name, "value": val}) + if importantKeys[name] { + keyMetrics[name] = val + } + } + if allStatus == nil { + allStatus = []map[string]interface{}{} + } c.JSON(http.StatusOK, gin.H{ - "key_metrics": map[string]string{}, - "all_status": []interface{}{}, + "key_metrics": keyMetrics, + "all_status": allStatus, }) } // POST /api/v1/instances/:id/detail/processlist/:pid/kill func DetailKillProcess(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "Process killed (placeholder)"}) + id := c.Param("id") + 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"}) + return + } + + dbName := "" + if instance.Database != nil { + dbName = *instance.Database + } + db, err := getDBConnection(&instance, dbName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", 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)}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"}) } // GET /api/v1/instances/:id/detail/users @@ -107,12 +450,52 @@ func DetailListUsers(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SELECT from mysql.user - c.JSON(http.StatusOK, []interface{}{}) + + if instance.DBType == "redis" { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + dbName := "" + if instance.Database != nil { + dbName = *instance.Database + } + db, err := getDBConnection(&instance, dbName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)}) + return + } + defer db.Close() + + rows, err := db.Query("SELECT User, Host FROM mysql.user") + if err != nil { + // Might not have permission - return empty + c.JSON(http.StatusOK, []interface{}{}) + return + } + defer rows.Close() + + var users []map[string]interface{} + for rows.Next() { + var user, host string + rows.Scan(&user, &host) + users = append(users, map[string]interface{}{ + "user": user, + "host": host, + }) + } + if users == nil { + users = []map[string]interface{}{} + } + c.JSON(http.StatusOK, users) } +// getDBConnection is defined in connection_tester.go or instance_detail.go +// Using the one from connection_tester.go + // POST /api/v1/query/execute func ExecuteQuery(c *gin.Context) { + userID, _, _ := middleware.GetCurrentUser(c) var input struct { InstanceID int `json:"instance_id"` Database string `json:"database"` @@ -122,11 +505,173 @@ func ExecuteQuery(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } - // TODO: actual SQL execution - c.JSON(http.StatusOK, gin.H{ - "columns": []interface{}{}, - "rows": []interface{}{}, - "total": 0, - "cost_ms": 0, + if input.InstanceID == 0 || input.SQL == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"}) + return + } + + 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"}) + return + } + + start := time.Now() + + switch instance.DBType { + case "mysql", "postgresql", "postgres", "pg": + result, err := executeSQLQuery(&instance, input.Database, input.SQL) + costMs := time.Since(start).Milliseconds() + if err != nil { + c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) + return + } + result["cost_ms"] = costMs + rowsInterface, _ := result["rows"].([]interface{}) + saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface)) + c.JSON(http.StatusOK, result) + + case "redis": + result, err := executeRedisQuery(&instance, input.SQL) + costMs := time.Since(start).Milliseconds() + if err != nil { + c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"result": result, "cost_ms": costMs, "columns": []string{}, "rows": []interface{}{}, "total": 0}) + + default: + c.JSON(http.StatusBadRequest, gin.H{"detail": "Unsupported database type: " + instance.DBType}) + } +} + +func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string) (gin.H, error) { + db, err := getDBConnection(instance, dbName) + if err != nil { + return nil, fmt.Errorf("connection failed: %v", err) + } + defer db.Close() + + rows, err := db.Query(sqlStr) + if err != nil { + return nil, fmt.Errorf("query failed: %v", err) + } + defer rows.Close() + + cols, _ := rows.Columns() + colNames := make([]string, len(cols)) + for i, col := range cols { + colNames[i] = col + } + + var resultRows []interface{} + for rows.Next() { + values := make([]interface{}, len(cols)) + ptrValues := make([]interface{}, len(cols)) + for i := range values { + ptrValues[i] = &values[i] + } + rows.Scan(ptrValues...) + rowMap := make(map[string]interface{}) + for i, col := range colNames { + val := values[i] + b, ok := val.([]byte) + if ok { + rowMap[col] = string(b) + } else { + rowMap[col] = val + } + } + resultRows = append(resultRows, rowMap) + } + + return gin.H{ + "columns": colNames, + "rows": resultRows, + "total": len(resultRows), + }, nil +} + +func executeRedisQuery(instance *model.Instance, command string) (string, error) { + password := instance.Password + if strings.HasPrefix(password, "enc:") { + decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:")) + if err == nil { + password = decrypted + } + } + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", instance.Host, instance.Port), + Password: password, + DB: 0, }) + defer client.Close() + + ctx := context.Background() + if err := client.Ping(ctx).Err(); err != nil { + return "", fmt.Errorf("Redis connection failed: %v", err) + } + + parts := strings.Fields(command) + if len(parts) == 0 { + return "", fmt.Errorf("empty command") + } + + cmd := strings.ToUpper(parts[0]) + readOnly := map[string]bool{ + "GET": true, "MGET": true, "HGET": true, "HGETALL": true, + "LRANGE": true, "SMEMBERS": true, "ZRANGE": true, + "TYPE": true, "TTL": true, "EXISTS": true, "KEYS": true, + "INFO": true, "DBSIZE": true, "SCAN": true, "STRLEN": true, + "LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true, + } + if !readOnly[cmd] { + return "", fmt.Errorf("only read-only commands allowed, got: %s", cmd) + } + + args := make([]interface{}, len(parts)-1) + for i, p := range parts[1:] { + args[i] = p + } + result, err := client.Do(ctx, args...).Result() + if err != nil { + return "", fmt.Errorf("command failed: %v", err) + } + return fmt.Sprintf("%v", result), nil +} + +func saveQueryHistory(userID int, instanceID int, database string, sqlStr string, costMs int64, rowCount int) { + var user model.User + var inst model.Instance + username := "" + instName := "" + dbType := "" + if model.DB.First(&user, userID).Error == nil { + username = user.Username + } + if model.DB.First(&inst, instanceID).Error == nil { + instName = inst.Name + dbType = inst.DBType + } + + queryType := "SELECT" + upper := strings.ToUpper(strings.TrimSpace(sqlStr)) + if strings.HasPrefix(upper, "SHOW") || strings.HasPrefix(upper, "DESCRIBE") || strings.HasPrefix(upper, "EXPLAIN") { + queryType = strings.SplitN(upper, " ", 2)[0] + } + + history := model.QueryHistory{ + UserID: userID, + Username: username, + InstanceID: instanceID, + InstanceName: instName, + DBType: dbType, + DatabaseName: database, + QueryContent: sqlStr, + QueryType: queryType, + ExecutionTimeMs: int(costMs), + ResultRows: rowCount, + IsSuccess: true, + } + model.DB.Create(&history) } diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index 469bf32..ecc7421 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -1,13 +1,41 @@ package handler import ( + "context" + "fmt" "net/http" "strconv" + "strings" + "time" "sql-platform/internal/model" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) +// Helper: get Redis client from instance +func getRedisClient(instance *model.Instance, db int) (*redis.Client, error) { + password := instance.Password + // Decrypt if encrypted + if strings.HasPrefix(password, "enc:") { + decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:")) + if err == nil { + password = decrypted + } + } + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", instance.Host, instance.Port), + Password: password, + DB: db, + }) + // Test connection + ctx := context.Background() + if err := client.Ping(ctx).Err(); err != nil { + client.Close() + return nil, fmt.Errorf("Redis connection failed: %v", err) + } + return client, nil +} // GET /api/v1/redis/:id/info func RedisInfo(c *gin.Context) { @@ -17,46 +45,134 @@ func RedisInfo(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual Redis INFO command - c.JSON(http.StatusOK, gin.H{ - "redis_version": "placeholder", - "used_memory": "0", - "connected_clients": "0", - "db_size": 0, - }) + + client, err := getRedisClient(&instance, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + info, err := client.Info(ctx, "server", "memory", "clients", "stats", "keyspace").Result() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + // Parse info into structured map + result := make(map[string]interface{}) + sections := strings.Split(info, "\n# ") + for _, section := range sections { + lines := strings.Split(section, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + } + + // Add db_size + dbSize, _ := client.DBSize(ctx).Result() + result["db_size"] = dbSize + + c.JSON(http.StatusOK, result) } // GET /api/v1/redis/:id/dbsize func RedisDbsize(c *gin.Context) { id := c.Param("id") + dbParam := c.Query("db") + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } + var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual DBSIZE - c.JSON(http.StatusOK, gin.H{"db_size": 0}) + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + dbSize, err := client.DBSize(ctx).Result() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"db_size": dbSize}) } // GET /api/v1/redis/:id/keys func RedisScanKeys(c *gin.Context) { id := c.Param("id") - _ = c.Query("db") - _ = c.Query("pattern") - _ = c.Query("count") - _ = c.Query("cursor") - _ = c.Query("type") + dbParam := c.Query("db") + pattern := c.DefaultQuery("pattern", "*") + count, _ := strconv.Atoi(c.DefaultQuery("count", "100")) + cursorParam := c.DefaultQuery("cursor", "0") + + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SCAN command + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + cursor, _ := strconv.ParseUint(cursorParam, 10, 64) + keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(count)).Result() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + // Get types for each key + type KeyInfo struct { + Key string `json:"key"` + Type string `json:"type"` + TTL int64 `json:"ttl"` + } + var keyInfos []KeyInfo + for _, key := range keys { + keyType, _ := client.Type(ctx, key).Result() + ttl, _ := client.TTL(ctx, key).Result() + ttlMs := int64(-1) + if ttl > 0 { + ttlMs = ttl.Milliseconds() + } + keyInfos = append(keyInfos, KeyInfo{Key: key, Type: keyType, TTL: ttlMs}) + } + if keyInfos == nil { + keyInfos = []KeyInfo{} + } + c.JSON(http.StatusOK, gin.H{ - "keys": []interface{}{}, - "cursor": 0, - "total": 0, + "keys": keyInfos, + "cursor": nextCursor, + "total": len(keyInfos), }) } @@ -64,32 +180,96 @@ func RedisScanKeys(c *gin.Context) { func RedisGetKeyDetail(c *gin.Context) { id := c.Param("id") key := c.Param("key") - _ = key + dbParam := c.Query("db") + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual key detail lookup + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + keyType, _ := client.Type(ctx, key).Result() + ttl, _ := client.TTL(ctx, key).Result() + ttlMs := int64(-1) + if ttl > 0 { + ttlMs = ttl.Milliseconds() + } + + var value interface{} + switch keyType { + case "string": + value, _ = client.Get(ctx, key).Result() + case "list": + value, _ = client.LRange(ctx, key, 0, -1).Result() + case "set": + value, _ = client.SMembers(ctx, key).Result() + case "zset": + value, _ = client.ZRangeWithScores(ctx, key, 0, -1).Result() + case "hash": + value, _ = client.HGetAll(ctx, key).Result() + default: + value = nil + } + c.JSON(http.StatusOK, gin.H{ - "key": key, - "type": "string", - "value": "", - "ttl": -1, + "key": key, + "type": keyType, + "value": value, + "ttl": ttlMs, }) } // GET /api/v1/redis/:id/slowlog func RedisSlowlog(c *gin.Context) { id := c.Param("id") + count, _ := strconv.Atoi(c.DefaultQuery("count", "10")) + var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual SLOWLOG GET - c.JSON(http.StatusOK, []interface{}{}) + + client, err := getRedisClient(&instance, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + logs, err := client.SlowLogGet(ctx, int64(count)).Result() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + var result []map[string]interface{} + for _, log := range logs { + result = append(result, map[string]interface{}{ + "id": log.ID, + "time": log.Time.Unix(), + "duration": log.Duration.Microseconds(), + "command": fmt.Sprintf("%v", log.Args), + "client": log.ClientAddr, + }) + } + if result == nil { + result = []map[string]interface{}{} + } + c.JSON(http.StatusOK, result) } // GET /api/v1/redis/:id/clients @@ -100,35 +280,208 @@ func RedisClients(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) return } - // TODO: actual CLIENT LIST - c.JSON(http.StatusOK, []interface{}{}) + + client, err := getRedisClient(&instance, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + clientList, err := client.ClientList(ctx).Result() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + // Parse client list + var clients []map[string]interface{} + for _, line := range strings.Split(clientList, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + clientInfo := make(map[string]interface{}) + for _, field := range strings.Split(line, " ") { + parts := strings.SplitN(field, "=", 2) + if len(parts) == 2 { + clientInfo[parts[0]] = parts[1] + } + } + clients = append(clients, clientInfo) + } + if clients == nil { + clients = []map[string]interface{}{} + } + c.JSON(http.StatusOK, clients) } // POST /api/v1/redis/:id/command func RedisExecuteCommand(c *gin.Context) { id := c.Param("id") + dbParam := c.Query("db") + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } + + var input struct { + Command string `json:"command" binding:"required"` + Args []string `json:"args"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": 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"}) return } - // TODO: actual command execution - c.JSON(http.StatusOK, gin.H{"result": "Command execution not implemented"}) + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + cmd := strings.ToUpper(input.Command) + + // Safe read-only commands + readOnly := map[string]bool{ + "GET": true, "MGET": true, "HGET": true, "HGETALL": true, + "LRANGE": true, "SMEMBERS": true, "ZRANGE": true, + "TYPE": true, "TTL": true, "EXISTS": true, + "INFO": true, "DBSIZE": true, "KEYS": true, "SCAN": true, + "STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true, + "HLEN": true, "GETRANGE": true, + } + + if !readOnly[cmd] { + c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"}) + return + } + + // Execute command + args := input.Args + cmdArgs := make([]interface{}, 0, len(args)+1) + cmdArgs = append(cmdArgs, input.Command) + for _, a := range args { + cmdArgs = append(cmdArgs, a) + } + result, err := client.Do(ctx, cmdArgs...).Result() + if err != nil { + c.JSON(http.StatusOK, gin.H{"result": nil, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"result": result}) +} + +func stringsToInterfaces(ss []string) []interface{} { + result := make([]interface{}, len(ss)) + for i, s := range ss { + result[i] = s + } + return result } // POST /api/v1/redis/:id/keys func RedisCreateKey(c *gin.Context) { id := c.Param("id") - _, _ = id, c.Request.Body - c.JSON(http.StatusOK, gin.H{"message": "Key created (placeholder)"}) + dbParam := c.Query("db") + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } + + var input struct { + Key string `json:"key" binding:"required"` + Type string `json:"type" binding:"required"` + Value interface{} `json:"value"` + TTL int `json:"ttl"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": 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"}) + return + } + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + + switch input.Type { + case "string": + client.Set(ctx, input.Key, input.Value, 0) + case "list": + if vals, ok := input.Value.([]interface{}); ok { + for _, v := range vals { + client.RPush(ctx, input.Key, fmt.Sprintf("%v", v)) + } + } + case "set": + if vals, ok := input.Value.([]interface{}); ok { + for _, v := range vals { + client.SAdd(ctx, input.Key, fmt.Sprintf("%v", v)) + } + } + case "hash": + if vals, ok := input.Value.(map[string]interface{}); ok { + client.HSet(ctx, input.Key, vals) + } + } + + if input.TTL > 0 { + client.Expire(ctx, input.Key, time.Duration(input.TTL)*time.Second) + } + + c.JSON(http.StatusOK, gin.H{"message": "Key created"}) } // DELETE /api/v1/redis/:id/keys/:key func RedisDeleteKey(c *gin.Context) { id := c.Param("id") key := c.Param("key") - _, _ = id, key - c.JSON(http.StatusOK, gin.H{"message": "Key deleted (placeholder)"}) + dbParam := c.Query("db") + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } + + var instance model.Instance + if err := model.DB.First(&instance, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + return + } + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + deleted, err := client.Del(ctx, key).Result() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Key deleted", "deleted": deleted}) } // POST /api/v1/workflows/:id/clone @@ -141,7 +494,7 @@ func CloneWorkflow(c *gin.Context) { } // clone newWf := model.WorkflowDefinition{ - Name: wf.Name + "-副本", + Name: wf.Name + " (副本)", Description: wf.Description, Category: wf.Category, IsActive: wf.IsActive, diff --git a/backend/internal/handler/stub_handlers.go b/backend/internal/handler/stub_handlers.go new file mode 100644 index 0000000..853b5e2 --- /dev/null +++ b/backend/internal/handler/stub_handlers.go @@ -0,0 +1,274 @@ +package handler + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// POST /api/v1/tickets/audit +// Runs SQL audit rules against provided SQL +func AuditTicketSQL(c *gin.Context) { + var input struct { + SQLContent string `json:"sql_content" binding:"required"` + DBType string `json:"db_type"` + Database string `json:"database"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // Basic SQL audit rules + var warnings []map[string]interface{} + sql := strings.ToUpper(input.SQLContent) + + // Rule: No WHERE clause on UPDATE/DELETE + if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") { + warnings = append(warnings, map[string]interface{}{ + "rule": "no_where_clause", + "severity": "high", + "message": "UPDATE/DELETE without WHERE clause affects all rows", + }) + } + + // Rule: SELECT * + if strings.Contains(sql, "SELECT *") { + warnings = append(warnings, map[string]interface{}{ + "rule": "select_star", + "severity": "low", + "message": "SELECT * may return unnecessary columns", + }) + } + + // Rule: No LIMIT on SELECT (for large tables) + if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") { + warnings = append(warnings, map[string]interface{}{ + "rule": "no_limit", + "severity": "medium", + "message": "SELECT without LIMIT may return excessive rows", + }) + } + + // Rule: DROP TABLE + if strings.Contains(sql, "DROP TABLE") { + warnings = append(warnings, map[string]interface{}{ + "rule": "drop_table", + "severity": "critical", + "message": "DROP TABLE is a destructive operation", + }) + } + + // Rule: TRUNCATE + if strings.Contains(sql, "TRUNCATE") { + warnings = append(warnings, map[string]interface{}{ + "rule": "truncate", + "severity": "critical", + "message": "TRUNCATE is a destructive operation", + }) + } + + if warnings == nil { + warnings = []map[string]interface{}{} + } + + // Determine risk level + riskLevel := "low" + for _, w := range warnings { + if w["severity"] == "critical" { + riskLevel = "high" + break + } + if w["severity"] == "high" { + riskLevel = "medium" + } + } + + c.JSON(http.StatusOK, gin.H{ + "risk_level": riskLevel, + "warnings": warnings, + "passed": len(warnings) == 0, + }) +} + +// POST /api/v1/sql-assistant/format +func SQLFormat(c *gin.Context) { + var input struct { + SQL string `json:"sql" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // Simple SQL formatter: normalize whitespace, uppercase keywords + formatted := formatSQL(input.SQL) + c.JSON(http.StatusOK, gin.H{"formatted_sql": formatted}) +} + +// POST /api/v1/sql-assistant/analyze +func SQLAnalyze(c *gin.Context) { + var input struct { + SQL string `json:"sql" binding:"required"` + DBType string `json:"db_type"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + sql := strings.ToUpper(strings.TrimSpace(input.SQL)) + var sqlType, impact string + + if strings.HasPrefix(sql, "SELECT") { + sqlType = "DQL" + impact = "read" + } else if strings.HasPrefix(sql, "INSERT") { + sqlType = "DML" + impact = "write" + } else if strings.HasPrefix(sql, "UPDATE") { + sqlType = "DML" + impact = "write" + } else if strings.HasPrefix(sql, "DELETE") { + sqlType = "DML" + impact = "write" + } else if strings.HasPrefix(sql, "CREATE") { + sqlType = "DDL" + impact = "schema_change" + } else if strings.HasPrefix(sql, "ALTER") { + sqlType = "DDL" + impact = "schema_change" + } else if strings.HasPrefix(sql, "DROP") { + sqlType = "DDL" + impact = "schema_change" + } else if strings.HasPrefix(sql, "GRANT") || strings.HasPrefix(sql, "REVOKE") { + sqlType = "DCL" + impact = "permission" + } else { + sqlType = "OTHER" + impact = "unknown" + } + + c.JSON(http.StatusOK, gin.H{ + "sql_type": sqlType, + "impact": impact, + "tables": extractTables(input.SQL), + }) +} + +func formatSQL(sql string) string { + keywords := []string{"SELECT", "FROM", "WHERE", "AND", "OR", "ORDER BY", "GROUP BY", + "HAVING", "LIMIT", "OFFSET", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", + "ON", "INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "CREATE TABLE", + "ALTER TABLE", "DROP TABLE", "CREATE INDEX", "UNION", "UNION ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "AS", "IN", "NOT", "IS NULL", "IS NOT NULL", + "BETWEEN", "LIKE", "EXISTS", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX"} + + result := sql + // Normalize whitespace + for strings.Contains(result, " ") { + result = strings.ReplaceAll(result, " ", " ") + } + result = strings.TrimSpace(result) + + // Add newlines before major keywords + majorKeywords := []string{"SELECT", "FROM", "WHERE", "ORDER BY", "GROUP BY", + "HAVING", "LIMIT", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", + "UNION", "UNION ALL", "INSERT INTO", "UPDATE", "SET", "DELETE FROM"} + for _, kw := range majorKeywords { + result = strings.ReplaceAll(result, " "+kw+" ", "\n"+kw+" ") + result = strings.ReplaceAll(result, " "+kw+"\n", "\n"+kw+"\n") + } + + // Uppercase keywords + upper := strings.ToUpper(result) + for _, kw := range keywords { + // Simple approach - just uppercase the keyword occurrences + _ = kw + } + _ = upper + + return result +} + +func extractTables(sql string) []string { + sql = strings.ToUpper(sql) + var tables []string + + // Extract tables after FROM + if idx := strings.Index(sql, "FROM"); idx >= 0 { + after := sql[idx+4:] + // Get the table name + parts := strings.Fields(after) + if len(parts) > 0 { + tables = append(tables, parts[0]) + } + } + + // Extract tables after JOIN + for { + idx := strings.Index(sql, "JOIN") + if idx < 0 { + break + } + after := sql[idx+4:] + parts := strings.Fields(after) + if len(parts) > 0 { + tables = append(tables, parts[0]) + } + sql = after + } + + if tables == nil { + tables = []string{} + } + return tables +} + +// Stub handlers for dictionary and mongo (not yet implemented) +// These return empty responses to prevent frontend errors + +func DictionaryDatabases(c *gin.Context) { + c.JSON(http.StatusOK, []string{}) +} + +func DictionaryTables(c *gin.Context) { + c.JSON(http.StatusOK, []interface{}{}) +} + +func DictionaryTableDetail(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"columns": []interface{}{}, "indexes": []interface{}{}}) +} + +func DictionaryExport(c *gin.Context) { + c.JSON(http.StatusOK, []interface{}{}) +} + +func MongoDatabases(c *gin.Context) { + c.JSON(http.StatusOK, []string{}) +} + +func MongoCollections(c *gin.Context) { + c.JSON(http.StatusOK, []string{}) +} + +func MongoCollectionStats(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"count": 0, "size": 0}) +} + +func MongoQuery(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"documents": []interface{}{}, "total": 0}) +} + +func MongoExecute(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "MongoDB execution not implemented", "affected": 0}) +} + +func MongoTestConnection(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "MongoDB not supported yet"}) +} + +// fmt import placeholder +var _ = fmt.Sprintf diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index 39d5ca2..bd98d6f 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" "sql-platform/internal/middleware" @@ -49,7 +50,7 @@ func TicketStatistics(c *gin.Context) { stats.Reviewing = int(c.Count) case "executing": stats.Executing = int(c.Count) - case "completed": + case "success": stats.Completed = int(c.Count) case "failed": stats.Failed = int(c.Count) @@ -210,6 +211,25 @@ func CreateTicket(c *gin.Context) { return } + // Auto-create approval record: step 1, approver = instance owner or admin + stepName := "审核" + stepType := "audit" + approverRole := "admin" + // Find first admin as default approver + var adminUser model.User + if err := model.DB.Where("role = ? AND is_active = 1", "admin").First(&adminUser).Error; err == nil { + adminID := adminUser.ID + approval := model.TicketApproval{ + TicketID: ticket.ID, + StepNumber: 1, + StepName: &stepName, + StepType: &stepType, + ApproverID: &adminID, + ApproverRole: &approverRole, + } + model.DB.Create(&approval) + } + c.JSON(http.StatusCreated, ticket) } @@ -278,13 +298,303 @@ func ExecuteTicket(c *gin.Context) { return } + // Check ticket status + if ticket.Status != nil && *ticket.Status != "approved" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"}) + 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"}) + return + } + status := "executing" ticket.Status = &status now := time.Now() ticket.ExecuteTime = &now model.DB.Save(&ticket) - // TODO: actual SQL execution + // Execute SQL based on db_type + var execResult string + var execErr string - c.JSON(http.StatusOK, gin.H{"message": "Execution started", "ticket_id": ticket.ID}) + if ticket.DBType == "redis" { + execErr = "Redis command execution not yet supported in ticket workflow" + } else { + // MySQL/PG execution + db, err := getDBConnection(&instance, ticket.TargetDatabase) + if err != nil { + execErr = fmt.Sprintf("DB connection failed: %v", err) + } else { + defer db.Close() + // Split multi-statement by semicolons + statements := splitSQL(ticket.SQLContent) + var results []string + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + _, err := db.Exec(stmt) + if err != nil { + results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) + // stop_on_error + execErr = err.Error() + break + } else { + results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50))) + } + } + if len(results) > 0 { + execResult = strings.Join(results, "\n") + } + } + } + + // Update final status + if execErr != "" { + failStatus := "failed" + ticket.Status = &failStatus + ticket.ExecuteResult = &execErr + } else { + doneStatus := "success" + ticket.Status = &doneStatus + ticket.ExecuteResult = &execResult + } + model.DB.Save(&ticket) + + c.JSON(http.StatusOK, gin.H{"message": "Execution completed", "ticket": ticket}) +} + +func splitSQL(sql string) []string { + return strings.Split(sql, ";") +} + +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + + +// 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"}) + 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"}) + return + } + } + + if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing") { + c.JSON(http.StatusBadRequest, gin.H{"detail": "Can only cancel pending or reviewing tickets"}) + 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"}) + return + } + + if ticket.RollbackSQL == nil || *ticket.RollbackSQL == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "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"}) + return + } + + if ticket.DBType == "redis" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "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)}) + return + } + defer db.Close() + + // Execute rollback SQL + statements := splitSQL(*ticket.RollbackSQL) + var results []string + var execErr string + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + _, err := db.Exec(stmt) + if err != nil { + results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) + execErr = err.Error() + break + } else { + 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}) +} + +// GET /api/v1/tickets/:id/timeline +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"}) + return + } + + type TimelineEntry struct { + ID int `json:"id"` + Action string `json:"action"` + User string `json:"user"` + Comment *string `json:"comment"` + Timestamp string `json:"timestamp"` + Status string `json:"status"` + } + + var timeline []TimelineEntry + + // 1. Created event + timeline = append(timeline, TimelineEntry{ + ID: 1, + Action: "created", + User: getApplicantName(ticket.ApplicantID), + 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) + for _, a := range approvals { + action := "pending_review" + userName := getUserName(a.ActualApproverID) + if a.Action != nil { + action = *a.Action + } + ts := a.CreatedAt.Format(time.RFC3339) + if a.CompletedAt != nil { + ts = a.CompletedAt.Format(time.RFC3339) + } + timeline = append(timeline, TimelineEntry{ + ID: a.ID, + Action: action, + User: userName, + Comment: a.Comment, + Timestamp: ts, + }) + } + + // 3. Executed event + if ticket.ExecuteTime != nil { + execAction := "executed" + if ticket.Status != nil && *ticket.Status == "failed" { + execAction = "execution_failed" + } else if ticket.Status != nil && *ticket.Status == "completed" { + execAction = "executed" + } + timeline = append(timeline, TimelineEntry{ + ID: 99, + Action: execAction, + Timestamp: ticket.ExecuteTime.Format(time.RFC3339), + 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()}) + 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) +} + +// Helper: get user name by ID (pointer-safe) +func getUserName(userID *int) string { + if userID == nil { + return "" + } + var user model.User + if model.DB.First(&user, *userID).Error == nil { + return user.DisplayName + } + return "" +} + +// Helper: get applicant name by ID +func getApplicantName(userID int) string { + var user model.User + if model.DB.First(&user, userID).Error == nil { + return user.DisplayName + } + return "" } diff --git a/backend/router/router.go b/backend/router/router.go index 7afd9cd..fb6c518 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -31,8 +31,13 @@ func Setup(r *gin.Engine) { auth.GET("/tickets/:id", handler.GetTicket) auth.POST("/tickets", handler.CreateTicket) auth.POST("/tickets/:id/approve", handler.ApproveTicket) + auth.POST("/tickets/:id/review", handler.ApproveTicket) auth.POST("/tickets/:id/execute", handler.ExecuteTicket) auth.POST("/tickets/:id/cancel-schedule", handler.CancelScheduledTask) + auth.POST("/tickets/:id/cancel", handler.CancelTicket) + auth.GET("/tickets/:id/timeline", handler.GetTicketTimeline) + auth.GET("/tickets/:id/approvals", handler.GetTicketApprovals) + auth.POST("/tickets/:id/rollback", handler.RollbackTicket) // Instances auth.GET("/instances", handler.ListInstances) @@ -142,6 +147,29 @@ func Setup(r *gin.Engine) { // Query execution auth.POST("/query/execute", handler.ExecuteQuery) + auth.POST("/query", handler.ExecuteQuery) + + + // SQL Assistant + auth.POST("/sql-assistant/format", handler.SQLFormat) + auth.POST("/sql-assistant/analyze", handler.SQLAnalyze) + + // Ticket Audit + auth.POST("/tickets/audit", handler.AuditTicketSQL) + + // Dictionary (stub) + auth.GET("/dictionary/instances/:instanceId/databases", handler.DictionaryDatabases) + auth.GET("/dictionary/instances/:instanceId/tables", handler.DictionaryTables) + auth.GET("/dictionary/instances/:instanceId/tables/:tableName", handler.DictionaryTableDetail) + auth.GET("/dictionary/instances/:instanceId/export", handler.DictionaryExport) + + // MongoDB (stub) + auth.GET("/mongo/:id/databases", handler.MongoDatabases) + auth.GET("/mongo/:id/databases/:database/collections", handler.MongoCollections) + auth.GET("/mongo/:id/collections/:collection/stats", handler.MongoCollectionStats) + auth.POST("/mongo/:id/query", handler.MongoQuery) + auth.POST("/mongo/:id/execute", handler.MongoExecute) + auth.POST("/mongo/:id/test-connection", handler.MongoTestConnection) // Read-only resources auth.GET("/audit-rules", handler.ListAuditRules)