910 lines
24 KiB
Go
910 lines
24 KiB
Go
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 (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 {
|
||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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")
|
||
dbParam := c.Query("db")
|
||
rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*"))
|
||
if rawPattern == "" {
|
||
rawPattern = "*"
|
||
}
|
||
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 {
|
||
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()
|
||
|
||
// Get types for each key
|
||
type KeyInfo struct {
|
||
Key string `json:"key"`
|
||
Type string `json:"type"`
|
||
TTL int64 `json:"ttl"`
|
||
}
|
||
|
||
// 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 {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||
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 {
|
||
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()
|
||
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 {
|
||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||
return
|
||
}
|
||
|
||
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
|
||
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"})
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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")
|
||
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()
|
||
|
||
if err := writeRedisKeyValue(ctx, client, input.Key, input.Type, input.Value); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": 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 {
|
||
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()
|
||
if err := client.Del(ctx, key).Err(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
if err := writeRedisKeyValue(ctx, client, key, input.Type, input.Value); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": 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 {
|
||
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
|
||
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"})
|
||
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)
|
||
}
|