package handler import ( "context" "fmt" "net/http" "strconv" "strings" "time" "sql-platform/internal/handler/resp" "sql-platform/internal/model" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) // Helper: get Redis client from instance (auto-detect standalone vs cluster) type redisClientWrapper struct { Client *redis.Client ClusterClient *redis.ClusterClient isCluster bool } func (w *redisClientWrapper) Close() { if w.Client != nil { w.Client.Close() } if w.ClusterClient != nil { w.ClusterClient.Close() } } func (w *redisClientWrapper) Ping(ctx context.Context) *redis.StatusCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Ping(ctx) } return w.Client.Ping(ctx) } func (w *redisClientWrapper) Info(ctx context.Context, sections ...string) *redis.StringCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Info(ctx, sections...) } return w.Client.Info(ctx, sections...) } func (w *redisClientWrapper) DBSize(ctx context.Context) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.DBSize(ctx) } return w.Client.DBSize(ctx) } func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd { return w.Client.Scan(ctx, cursor, match, count) } func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, count int) ([]string, error) { if w.isCluster && w.ClusterClient != nil { var allKeys []string err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error { cursor := uint64(0) for { keys, nextCursor, err := shard.Scan(ctx, cursor, match, int64(count)).Result() if err != nil { return err } allKeys = append(allKeys, keys...) cursor = nextCursor if cursor == 0 { break } } return nil }) return allKeys, err } var allKeys []string cursor := uint64(0) for { keys, nextCursor, err := w.Client.Scan(ctx, cursor, match, int64(count)).Result() if err != nil { return allKeys, err } allKeys = append(allKeys, keys...) cursor = nextCursor if cursor == 0 { break } } return allKeys, nil } func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Type(ctx, key) } return w.Client.Type(ctx, key) } func (w *redisClientWrapper) TTL(ctx context.Context, key string) *redis.DurationCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.TTL(ctx, key) } return w.Client.TTL(ctx, key) } func (w *redisClientWrapper) Get(ctx context.Context, key string) *redis.StringCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Get(ctx, key) } return w.Client.Get(ctx, key) } func (w *redisClientWrapper) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.LRange(ctx, key, start, stop) } return w.Client.LRange(ctx, key, start, stop) } func (w *redisClientWrapper) SMembers(ctx context.Context, key string) *redis.StringSliceCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.SMembers(ctx, key) } return w.Client.SMembers(ctx, key) } func (w *redisClientWrapper) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *redis.ZSliceCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.ZRangeWithScores(ctx, key, start, stop) } return w.Client.ZRangeWithScores(ctx, key, start, stop) } func (w *redisClientWrapper) HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.HGetAll(ctx, key) } return w.Client.HGetAll(ctx, key) } func (w *redisClientWrapper) SlowLogGet(ctx context.Context, count int64) *redis.SlowLogCmd { return w.Client.SlowLogGet(ctx, count) } func (w *redisClientWrapper) ClientList(ctx context.Context) *redis.StringCmd { return w.Client.ClientList(ctx) } func (w *redisClientWrapper) Do(ctx context.Context, args ...interface{}) *redis.Cmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Do(ctx, args...) } return w.Client.Do(ctx, args...) } func (w *redisClientWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Set(ctx, key, value, expiration) } return w.Client.Set(ctx, key, value, expiration) } func (w *redisClientWrapper) RPush(ctx context.Context, key string, values ...interface{}) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.RPush(ctx, key, values...) } return w.Client.RPush(ctx, key, values...) } func (w *redisClientWrapper) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.SAdd(ctx, key, members...) } return w.Client.SAdd(ctx, key, members...) } func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.HSet(ctx, key, values...) } return w.Client.HSet(ctx, key, values...) } func (w *redisClientWrapper) ZAdd(ctx context.Context, key string, members ...redis.Z) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.ZAdd(ctx, key, members...) } return w.Client.ZAdd(ctx, key, members...) } func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Expire(ctx, key, expiration) } return w.Client.Expire(ctx, key, expiration) } func (w *redisClientWrapper) Del(ctx context.Context, keys ...string) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Del(ctx, keys...) } return w.Client.Del(ctx, keys...) } func (w *redisClientWrapper) LLen(ctx context.Context, key string) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.LLen(ctx, key) } return w.Client.LLen(ctx, key) } func (w *redisClientWrapper) SCard(ctx context.Context, key string) *redis.IntCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.SCard(ctx, key) } return w.Client.SCard(ctx, key) } func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.SScan(ctx, key, cursor, match, count) } return w.Client.SScan(ctx, key, cursor, match, count) } func writeRedisKeyValue(ctx context.Context, client *redisClientWrapper, key string, keyType string, value interface{}) error { switch keyType { case "string": return client.Set(ctx, key, fmt.Sprintf("%v", value), 0).Err() case "list": values, ok := value.([]interface{}) if !ok { return fmt.Errorf("list value must be an array") } if len(values) == 0 { return nil } items := make([]interface{}, 0, len(values)) for _, v := range values { items = append(items, fmt.Sprintf("%v", v)) } return client.RPush(ctx, key, items...).Err() case "set": values, ok := value.([]interface{}) if !ok { return fmt.Errorf("set value must be an array") } if len(values) == 0 { return nil } items := make([]interface{}, 0, len(values)) for _, v := range values { items = append(items, fmt.Sprintf("%v", v)) } return client.SAdd(ctx, key, items...).Err() case "hash": values, ok := value.(map[string]interface{}) if !ok { return fmt.Errorf("hash value must be an object") } if len(values) == 0 { return nil } return client.HSet(ctx, key, values).Err() case "zset": values, ok := value.([]interface{}) if !ok { return fmt.Errorf("zset value must be an array") } members := make([]redis.Z, 0, len(values)) for _, row := range values { m, ok := row.(map[string]interface{}) if !ok { return fmt.Errorf("zset rows must be objects with member and score") } member, ok := m["member"] if !ok { member = m["Member"] } scoreRaw, ok := m["score"] if !ok { scoreRaw = m["Score"] } score, err := strconv.ParseFloat(fmt.Sprintf("%v", scoreRaw), 64) if err != nil { return fmt.Errorf("invalid zset score: %v", scoreRaw) } members = append(members, redis.Z{Member: fmt.Sprintf("%v", member), Score: score}) } if len(members) == 0 { return nil } return client.ZAdd(ctx, key, members...).Err() default: return fmt.Errorf("unsupported redis type: %s", keyType) } } func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, error) { password := instance.Password // Decrypt if encrypted if strings.HasPrefix(password, "enc:") { decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:")) if err == nil { password = decrypted } } addr := fmt.Sprintf("%s:%d", instance.Host, instance.Port) // Redis 6.0+ ACL: username is optional (empty = use "default" traditional auth) redisUsername := instance.Username if redisUsername == "default" || redisUsername == "" { redisUsername = "" // go-redis treats empty Username as no ACL auth } client := redis.NewClient(&redis.Options{ Addr: addr, Username: redisUsername, Password: password, DB: db, DialTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, }) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := client.Ping(ctx).Err(); err != nil { client.Close() return nil, fmt.Errorf("Redis connection failed: %v", err) } // Check cluster mode flag for wrapper behavior (ScanAllKeys needs to know) isCluster := instance.ClusterMode != nil && *instance.ClusterMode addrs := []string{addr} if slots, err := client.ClusterSlots(ctx).Result(); err == nil && len(slots) > 0 { isCluster = true seen := map[string]bool{} addrs = addrs[:0] for _, slot := range slots { if len(slot.Nodes) == 0 { continue } nodeAddr := slot.Nodes[0].Addr if idx := strings.LastIndex(nodeAddr, ":"); idx >= 0 { nodeAddr = instance.Host + nodeAddr[idx:] } if !seen[nodeAddr] { seen[nodeAddr] = true addrs = append(addrs, nodeAddr) } } if len(addrs) == 0 { addrs = []string{addr} } } wrapper := &redisClientWrapper{Client: client, isCluster: isCluster} if !isCluster { return wrapper, nil } clusterClient := redis.NewClusterClient(&redis.ClusterOptions{ Addrs: addrs, Username: redisUsername, Password: password, DialTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, }) if err := clusterClient.Ping(ctx).Err(); err != nil { clusterClient.Close() client.Close() return nil, fmt.Errorf("Redis cluster connection failed: %v", err) } wrapper.ClusterClient = clusterClient return wrapper, nil } // GET /api/v1/redis/:id/info func RedisInfo(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, 0) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() info, err := client.Info(ctx).Result() if err != nil { resp.InternalErr(c, err) return } // Parse Redis INFO into both a flat map and section groups. result := make(map[string]interface{}) grouped := make(map[string]map[string]interface{}) currentSection := "General" sections := strings.Split(info, "\n# ") for _, section := range sections { lines := strings.Split(section, "\n") for lineIndex, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if strings.HasPrefix(line, "#") { currentSection = strings.TrimSpace(strings.TrimPrefix(line, "#")) continue } if lineIndex == 0 && !strings.Contains(line, ":") { currentSection = strings.TrimSpace(line) continue } parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) result[key] = value if _, ok := grouped[currentSection]; !ok { grouped[currentSection] = make(map[string]interface{}) } grouped[currentSection][key] = value } } } // Add db_size dbSize, _ := client.DBSize(ctx).Result() result["db_size"] = dbSize if _, ok := grouped["Keyspace"]; !ok { grouped["Keyspace"] = make(map[string]interface{}) } grouped["Keyspace"]["db_size"] = dbSize c.JSON(http.StatusOK, gin.H{ "info": result, "sections": grouped, }) } // 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 { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() dbSize, err := client.DBSize(ctx).Result() if err != nil { resp.InternalErr(c, err) 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") dbParam := c.Query("db") type KeyInfo struct { Key string `json:"key"` Type string `json:"type"` TTL int64 `json:"ttl"` } rawPattern := strings.TrimSpace(c.Query("pattern")) if rawPattern == "" { c.JSON(http.StatusOK, gin.H{ "keys": []KeyInfo{}, "cursor": 0, "total": 0, }) return } pattern := rawPattern hasWildcard := strings.ContainsAny(pattern, "*?[") count, _ := strconv.Atoi(c.DefaultQuery("count", "100")) db := 0 if dbParam != "" { db, _ = strconv.Atoi(dbParam) } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() // Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master, // 在 NAT/代理暴露单入口时容易受节点通告地址影响。 if !hasWildcard && pattern != "*" { keyType, err := client.Type(ctx, pattern).Result() if err == nil && keyType != "none" { ttl, _ := client.TTL(ctx, pattern).Result() ttlMs := int64(-1) if ttl > 0 { ttlMs = ttl.Milliseconds() } c.JSON(http.StatusOK, gin.H{ "keys": []KeyInfo{{Key: pattern, Type: keyType, TTL: ttlMs}}, "cursor": 0, "total": 1, }) return } } // Auto-append * if pattern has no wildcard (user-friendly fuzzy search) if !hasWildcard { pattern = pattern + "*" } allKeys, err := client.ScanAllKeys(ctx, pattern, count) if err != nil { resp.InternalErr(c, err) return } var keyInfos []KeyInfo for _, key := range allKeys { 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": keyInfos, "cursor": 0, "total": len(keyInfos), }) } // GET /api/v1/redis/:id/keys/:key func RedisGetKeyDetail(c *gin.Context) { id := c.Param("id") key := c.Param("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 { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) 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": 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 { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, 0) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() logs, err := client.SlowLogGet(ctx, int64(count)).Result() if err != nil { resp.InternalErr(c, err) 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 func RedisClients(c *gin.Context) { id := c.Param("id") var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, 0) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() clientList, err := client.ClientList(ctx).Result() if err != nil { resp.InternalErr(c, err) 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 { resp.BadRequest(c, err.Error()) return } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) 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] { resp.Forbidden(c, "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") 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 { resp.BadRequest(c, err.Error()) return } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() if err := writeRedisKeyValue(ctx, client, input.Key, input.Type, input.Value); err != nil { resp.BadRequest(c, err.Error()) return } if input.TTL > 0 { client.Expire(ctx, input.Key, time.Duration(input.TTL)*time.Second) } c.JSON(http.StatusOK, gin.H{"message": "Key created"}) } // PUT /api/v1/redis/:id/keys/:key func RedisUpdateKey(c *gin.Context) { id := c.Param("id") key := c.Param("key") dbParam := c.Query("db") db := 0 if dbParam != "" { db, _ = strconv.Atoi(dbParam) } var input struct { Type string `json:"type" binding:"required"` Value interface{} `json:"value"` TTL int `json:"ttl"` } if err := c.ShouldBindJSON(&input); err != nil { resp.BadRequest(c, err.Error()) return } var instance model.Instance if err := model.DB.First(&instance, id).Error; err != nil { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() if err := client.Del(ctx, key).Err(); err != nil { resp.InternalErr(c, err) return } if err := writeRedisKeyValue(ctx, client, key, input.Type, input.Value); err != nil { resp.BadRequest(c, err.Error()) return } if input.TTL > 0 { client.Expire(ctx, key, time.Duration(input.TTL)*time.Second) } c.JSON(http.StatusOK, gin.H{"message": "Key updated"}) } // DELETE /api/v1/redis/:id/keys/:key func RedisDeleteKey(c *gin.Context) { id := c.Param("id") key := c.Param("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 { resp.NotFound(c, "Instance not found") return } client, err := getRedisClient(&instance, db) if err != nil { resp.InternalErr(c, err) return } defer client.Close() ctx := context.Background() deleted, err := client.Del(ctx, key).Result() if err != nil { resp.InternalErr(c, err) return } c.JSON(http.StatusOK, gin.H{"message": "Key deleted", "deleted": deleted}) } // POST /api/v1/workflows/:id/clone 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 { resp.NotFound(c, "Workflow not found") return } // clone newWf := model.WorkflowDefinition{ Name: wf.Name + " (副本)", Description: wf.Description, Category: wf.Category, IsActive: wf.IsActive, FlowConfig: wf.FlowConfig, } model.DB.Create(&newWf) c.JSON(http.StatusCreated, newWf) }