Files
opsxsql/backend/internal/handler/redis.go
T
opsx 7dfbc0b680 feat(redis): 大 key 分页 + 流式导出(含票据鉴权)+ 查询行数上限默认 5000
=== 一、问题:大 key 会把内存打爆,且查询结果没有导出 ===
RedisGetKeyDetail 原来对每种类型都是全量读取:
  string → GET        list → LRANGE 0 -1     set → SMEMBERS
  zset   → ZRANGE 0 -1                        hash → HGETALL
然后整包序列化返回。像 PERP:market:* 这类盘口/kline key,
一个 key 几十万条就会:Go 侧整值入内存 → JSON 序列化翻倍 → 浏览器渲染卡死。

=== 二、后端:键详情分页(统一令牌,兼容四种类型)===
GET /redis/:id/keys/:key?db=&count=&token=
  count  默认 200,硬上限 2000
  token  语义按类型区分,前端无感知:
           list / zset  → 十进制 offset(LRANGE / ZRANGE 区间)
           hash / set   → Redis SCAN 游标(HSCAN / SSCAN)
           string       → 忽略
  返回   total / count / nextToken / truncated / previewCap

string 特殊处理:不能分页,改为预览上限 256KB
  - 用 STRLEN 拿真实大小(O(1))
  - 超过上限时用 GETRANGE 只取前 256KB,truncated=true
  - 完整内容只能靠导出

新增 wrapper 方法:HScan / ZScan / HLen / ZCard / StrLen / GetRange
(HGETALL、SMEMBERS、ZRANGE 0 -1 一律不再用于详情读取)

=== 三、后端:流式导出(两段式票据,避免令牌进 URL 日志)===
① POST /redis/:id/keys/:key/export-ticket   (走 Bearer 鉴权)
     → HMAC-SHA256(JWTSecret, instance|db|key|exp) 生成票据,2 分钟过期,
       绑定具体 instance/db/key,不能跨 key 复用
② GET  /redis/:id/keys/:key/export?db=&format=csv|jsonl&ticket=
     → 注册在鉴权组【之外】,靠票据校验
     为什么这么做:浏览器原生下载(<a download>/window.open)带不了
     Authorization 头;若前端用 axios 取 blob,整份导出会进浏览器内存,
     与「大 key 不爆内存」的目标相悖。

导出实现全程流式(服务端内存 = 单批 + 64KB 缓冲):
  string → GETRANGE 256KB 分块
  list   → LRANGE 每批 1000
  zset   → ZRANGE WITHSCORES 每批 1000
  hash   → HSCAN 游标每批 1000
  set    → SSCAN 游标每批 1000
单次上限 200 万行;CSV 带 UTF-8 BOM(Excel 直接打开不乱码);
列顺:hash(field,value) / list(index,value) / set(member) / zset(rank,member,score)

=== 四、前端:详情面板加分页条 + 4 个导出按钮 ===
RedisKeyDetailPanel:
  - 「共 N 条 · 第 X 页 · 每页 [100/200/500/1000/2000]」+ 上一页/下一页
  - SCAN 游标不可逆,上一页用令牌栈回放
  - string 超预览上限时显示黄色提示「仅预览前 256KB,共 N 字节」
  - 导出四档:当前页 CSV / 当前页 JSON(纯前端,瞬时)
             导出全部 CSV / 导出全部 JSONL(服务端流式,边读边写)
  顺带修掉头注释里「已知限制:大值不分页」的过时说明。

=== 五、查询行数上限:默认 1000 → 5000,并修掉硬编码 ===
用户要求:「默认设置里面有查询最大上限吧 比如 mysql pg 查询最大Limit限制 5000」

发现两个问题:
1. seed 默认值只有 1000(main.go query_limit_max)
2. ExecuteQuery 里还有一段硬编码:
     if input.Limit <= 0 { input.Limit = maxLimit; if input.Limit > 1000 { input.Limit = 1000 } }
   → 管理员把设置改成 5000 也白搭,前端不传 limit 时永远被压回 1000

修复:
  - seed 默认值 1000 → 5000(Value + DefaultValue)
  - getSettingInt("query_limit_max", 1000) → 5000
  - 删掉硬编码 1000 封顶,唯一口径就是设置值
  - 线上已有记录的 value 已通过 PUT /settings 改成 5000(seed 只在缺失时插入,不会自动更新旧值)

对 MySQL / PostgreSQL / Redis / MongoDB 统一生效(ExecuteQuery 按 db_type 分发,
limit 在入口处统一 clamp)。

=== 六、实测结果(线上)===
分页:
  zset PERP:market:kline:symbol_kline_12h_AAVEUSDT_PERP_MARK
        total=83,count=5 → 第1页 5 条 nextToken=5 → 第2页 5 条 nextToken=10,数据不同 ✅
  hash ST:market:ticker:symbol_ticker
        total=27,count=3 → 第1页 3 字段 nextToken=24 → 第2页 3 字段(完全不同)✅
  string PERP:market:depth:symbol_depth_BTCUSDT_PERP
        STRLEN=14192 → 导出 14192 字节,完全一致 ✅(实时 key 两次采样有差异属正常)
导出:
  zset CSV  200 / 30970 字节 / 84 行(header+83)✅
  zset JSONL 200 / 84 行 ✅
  string CSV 200 / 分块 GETRANGE 拼接完整 ✅
  无票据 → 403 ✅   伪造票据 → 403 ✅
查询上限:
  limit=99999 → 5000 行 ✅    limit=0(默认)→ 5000 行 ✅
SQL 导出 CSV(原已存在 ExportQueryCSV,支持 mysql/pg/redis/mongo)200 / 101 行 ✅

镜像:lghost/opsxsql:latest + v0.1 = sha256:68fc996681c6
2026-10-27 09:14:02 +00:00

1139 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"fmt"
"net"
"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
// MasterAddrs 集群各 master 地址(排序);单机为单地址。扫描分片与游标校验依赖
MasterAddrs []string
Username string
Password string
}
func (w *redisClientWrapper) IsCluster() bool { return w.isCluster }
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)
}
// SlowLogGet 集群模式逐 master 聚合(单机连接打不到全部分片)
func (w *redisClientWrapper) SlowLogGet(ctx context.Context, count int64) ([]redis.SlowLog, error) {
if w.isCluster && w.ClusterClient != nil {
var all []redis.SlowLog
err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error {
logs, err := shard.SlowLogGet(ctx, count).Result()
if err != nil {
return err
}
all = append(all, logs...)
return nil
})
return all, err
}
return w.Client.SlowLogGet(ctx, count).Result()
}
// ClientList 集群模式逐 master 聚合
func (w *redisClientWrapper) ClientList(ctx context.Context) (string, error) {
if w.isCluster && w.ClusterClient != nil {
var sb strings.Builder
err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error {
list, err := shard.ClientList(ctx).Result()
if err != nil {
return err
}
sb.WriteString(list)
if !strings.HasSuffix(list, "\n") {
sb.WriteString("\n")
}
return nil
})
return sb.String(), err
}
return w.Client.ClientList(ctx).Result()
}
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) Persist(ctx context.Context, key string) *redis.BoolCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Persist(ctx, key)
}
return w.Client.Persist(ctx, key)
}
func (w *redisClientWrapper) HDel(ctx context.Context, key string, fields ...string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HDel(ctx, key, fields...)
}
return w.Client.HDel(ctx, key, fields...)
}
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)
}
// HScan 分批遍历 hash —— 大 key 不能用 HGETALL 整体读,会打爆内存。
func (w *redisClientWrapper) HScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HScan(ctx, key, cursor, match, count)
}
return w.Client.HScan(ctx, key, cursor, match, count)
}
// ZScan 分批遍历 zset 成员(不含 score,仅在需要成员集合时使用)。
func (w *redisClientWrapper) ZScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.ZScan(ctx, key, cursor, match, count)
}
return w.Client.ZScan(ctx, key, cursor, match, count)
}
// 以下几个 count/长度类命令用于分页时给出 total,都是 O(1),不会因为 key 大而变慢。
func (w *redisClientWrapper) HLen(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HLen(ctx, key)
}
return w.Client.HLen(ctx, key)
}
func (w *redisClientWrapper) ZCard(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.ZCard(ctx, key)
}
return w.Client.ZCard(ctx, key)
}
func (w *redisClientWrapper) StrLen(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.StrLen(ctx, key)
}
return w.Client.StrLen(ctx, key)
}
// GetRange 按字节区间读取字符串,用于超大 string 的分页预览与流式导出。
func (w *redisClientWrapper) GetRange(ctx context.Context, key string, start, end int64) *redis.StringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.GetRange(ctx, key, start, end)
}
return w.Client.GetRange(ctx, key, start, end)
}
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) {
// 防御:非 Redis 实例不能走 Redis 协议。
// 若前端误调 /api/v1/redis/:id/*(如共用组件的 MySQL 实例),
// 用 Redis 协议连 3306 会收到 MySQL 握手包,报
// `redis: invalid reply: "J\x00\x00\x00\n"` 并抛 500。
// 这里提前拦截,给出可读错误。
if instance.DBType != "redis" {
return nil, resp.UserFacing(fmt.Sprintf(
"实例「%s」的类型是 %s,不是 redis。Redis 接口只适用于 redis 实例;"+
"MySQL/PostgreSQL 请使用 /api/v1/instances/%d/databases 等对应接口",
instance.Name, instance.DBType, instance.ID))
}
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
// 只在 Redis 上报空/回环地址时才用配置 host 兜底(NAT / 端口映射场景)。
// 多机集群必须保留 Redis 上报的真实主机,否则会把跨机节点全部指向种子机,
// 导致 "dial tcp <种子机>:<其他机器的端口>: connection refused"。
if h, p, err := net.SplitHostPort(nodeAddr); err == nil {
if h == "" || h == "127.0.0.1" || h == "0.0.0.0" {
nodeAddr = net.JoinHostPort(instance.Host, p)
}
}
if !seen[nodeAddr] {
seen[nodeAddr] = true
addrs = append(addrs, nodeAddr)
}
}
if len(addrs) == 0 {
addrs = []string{addr}
}
}
wrapper := &redisClientWrapper{
Client: client,
isCluster: isCluster,
MasterAddrs: []string{addr},
Username: redisUsername,
Password: password,
}
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
wrapper.MasterAddrs = addrs
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/:key
// Redis 键详情分页参数:默认每页 200 条,硬上限 2000 条。
// 大 key(如 PERP:market:depth:* 盘口快照)必须分页,否则 HGETALL / LRANGE 0 -1
// 会把整个值读进内存再整包序列化,极易把 Pod 内存打爆、前端也会卡死。
const (
redisKeyPageDefault = 200
redisKeyPageMax = 2000
// string 类型无法「分页」,改为给一个预览上限,超出时标记 truncated 并给出总字节数。
redisStringPreviewBytes = 256 << 10
)
// GET /api/v1/redis/:id/keys/:key?db=0&count=200&token=<分页令牌>
//
// token 语义由类型决定:
// - list / zset → 十进制 offset(偏移量)
// - hash / set → Redis SCAN 游标
// - string → 忽略
//
// 返回 token(下一页令牌,空串=没有更多)、total、truncated。
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)
}
count, _ := strconv.Atoi(c.DefaultQuery("count", strconv.Itoa(redisKeyPageDefault)))
if count <= 0 {
count = redisKeyPageDefault
}
if count > redisKeyPageMax {
count = redisKeyPageMax
}
pageToken := c.Query("token")
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{}
var total int64 = -1
nextToken := ""
truncated := false
offset := int64(0)
if pageToken != "" {
if n, err := strconv.ParseInt(pageToken, 10, 64); err == nil && n > 0 {
offset = n
}
}
scanCursor := uint64(0)
if pageToken != "" {
if n, err := strconv.ParseUint(pageToken, 10, 64); err == nil {
scanCursor = n
}
}
switch keyType {
case "string":
size, _ := client.StrLen(ctx, key).Result()
total = size
// 用 GETRANGE 只取预览区间,避免超大字符串整串读入
if size > redisStringPreviewBytes {
value, _ = client.GetRange(ctx, key, 0, redisStringPreviewBytes-1).Result()
truncated = true
} else {
value, _ = client.Get(ctx, key).Result()
}
case "list":
total, _ = client.LLen(ctx, key).Result()
items, err := client.LRange(ctx, key, offset, offset+int64(count)-1).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
if items == nil {
items = []string{}
}
value = items
if offset+int64(len(items)) < total {
nextToken = strconv.FormatInt(offset+int64(len(items)), 10)
truncated = true
}
case "set":
total, _ = client.SCard(ctx, key).Result()
members, next, err := client.SScan(ctx, key, scanCursor, "", int64(count)).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
if members == nil {
members = []string{}
}
value = members
if next != 0 {
nextToken = strconv.FormatUint(next, 10)
truncated = true
}
case "zset":
total, _ = client.ZCard(ctx, key).Result()
items, err := client.ZRangeWithScores(ctx, key, offset, offset+int64(count)-1).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
if items == nil {
items = []redis.Z{}
}
value = items
if offset+int64(len(items)) < total {
nextToken = strconv.FormatInt(offset+int64(len(items)), 10)
truncated = true
}
case "hash":
total, _ = client.HLen(ctx, key).Result()
flat, next, err := client.HScan(ctx, key, scanCursor, "", int64(count)).Result()
if err != nil {
resp.InternalErr(c, err)
return
}
// HScan 返回扁平 [field, value, field, value, ...],这里还原成 map,
// 与旧的 HGetAll 返回结构保持一致,前端无需改动取值方式。
m := make(map[string]string, len(flat)/2)
for i := 0; i+1 < len(flat); i += 2 {
m[flat[i]] = flat[i+1]
}
value = m
if next != 0 {
nextToken = strconv.FormatUint(next, 10)
truncated = true
}
default:
value = nil
}
c.JSON(http.StatusOK, gin.H{
"key": key,
"type": keyType,
"value": value,
"ttl": ttlMs,
"total": total,
"count": count,
"nextToken": nextToken,
"truncated": truncated,
"pageToken": pageToken,
"previewCap": redisStringPreviewBytes,
})
}
// 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))
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)
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")
var input struct {
DB int `json:"db"`
Command string `json:"command" binding:"required"`
Args []string `json:"args"` // 兼容旧调用:显式参数
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, err.Error())
return
}
if v := c.Query("db"); v != "" && input.DB == 0 {
input.DB, _ = strconv.Atoi(v)
}
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, input.DB)
if err != nil {
resp.InternalErr(c, err)
return
}
defer client.Close()
ctx := context.Background()
// 命令解析:优先按整串引号感知拆分;整串为空时兼容旧的显式 args 调用
var verb string
var args []string
if _, parts := splitRedisCommand(input.Command); len(parts) > 0 {
verb = strings.ToUpper(parts[0])
args = parts[1:]
} else if len(input.Args) > 0 {
verb = strings.ToUpper(strings.TrimSpace(input.Args[0]))
args = input.Args[1:]
}
if verb == "" {
resp.BadRequest(c, "命令不能为空")
return
}
readOnly := map[string]bool{
"GET": true, "MGET": true, "HGET": true, "HGETALL": true, "HKEYS": true, "HVALS": true, "HSTRLEN": true,
"LRANGE": true, "LINDEX": true, "LLEN": true, "SMEMBERS": true, "SISMEMBER": true, "SCARD": true,
"ZRANGE": true, "ZREVRANGE": true, "ZSCORE": true, "ZCARD": true,
"TYPE": true, "TTL": true, "PTTL": true, "EXISTS": true, "STRLEN": true,
"INFO": true, "DBSIZE": true, "SCAN": true, "HSCAN": true, "SSCAN": true, "ZSCAN": true,
"GETRANGE": true, "OBJECT": true, "RANDOMKEY": true, "PING": true, "ECHO": true,
"XRANGE": true, "XLEN": true, "MEMORY": true, "BITCOUNT": true, "GETBIT": true,
}
if !readOnly[verb] {
resp.Forbidden(c, "仅允许只读命令,数据变更请走工单流程")
return
}
cmdArgs := make([]interface{}, 0, len(args)+1)
cmdArgs = append(cmdArgs, verb)
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": redisErrHint(err).Error()})
return
}
c.JSON(http.StatusOK, gin.H{"result": result})
}
// splitRedisCommand 按空白拆分命令串(双引号/单引号感知,支持引号内空格)
func splitRedisCommand(cmdline string) (verb string, args []string) {
var parts []string
var cur strings.Builder
inQuote := byte(0)
flush := func() {
if cur.Len() > 0 {
parts = append(parts, cur.String())
cur.Reset()
}
}
for i := 0; i < len(cmdline); i++ {
ch := cmdline[i]
switch {
case inQuote != 0:
if ch == inQuote {
inQuote = 0
} else {
cur.WriteByte(ch)
}
case ch == '"' || ch == '\'':
inQuote = ch
case ch == ' ' || ch == '\t':
flush()
default:
cur.WriteByte(ch)
}
}
flush()
if len(parts) == 0 {
return "", nil
}
return strings.ToUpper(parts[0]), parts
}
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()
// 记录原 TTL:未显式指定新 TTL 时保留原值(旧行为会静默丢 TTL)
prevTTL, _ := client.TTL(ctx, key).Result()
// string 覆盖写无需 DEL;容器类型可能收缩,需先清空再重写
if input.Type != "string" {
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
}
switch {
case input.TTL > 0:
client.Expire(ctx, key, time.Duration(input.TTL)*time.Second)
case input.TTL == -1:
client.Persist(ctx, key)
case prevTTL > 0:
client.Expire(ctx, key, prevTTL)
}
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)
}