package db import ( "context" "fmt" "sort" "strconv" "strings" "time" "github.com/redis/go-redis/v9" ) func redisDBIndex(c ConnReq) int { db := 0 if c.Database != "" { if n, err := strconv.Atoi(c.Database); err == nil { db = n } } return db } func normalizeRedisMode(c ConnReq) string { m := strings.ToLower(strings.TrimSpace(c.RedisMode)) if m == "" { return "standalone" } return m } // parseRedisAddrs 解析 host:port 列表(换行 / 逗号 / 分号 / 空白分隔)。 func parseRedisAddrs(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } raw = strings.ReplaceAll(raw, "\r\n", "\n") raw = strings.ReplaceAll(raw, "\r", "\n") raw = strings.ReplaceAll(raw, ",", " ") raw = strings.ReplaceAll(raw, ";", " ") raw = strings.ReplaceAll(raw, "\n", " ") parts := strings.Fields(raw) out := make([]string, 0, len(parts)) seen := map[string]struct{}{} for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } if _, ok := seen[p]; ok { continue } seen[p] = struct{}{} out = append(out, p) } return out } func redisStandaloneOpts(c ConnReq) (*redis.Options, error) { host := c.Host if host == "" { host = "127.0.0.1" } port := c.Port if port == 0 { port = 6379 } opts := &redis.Options{ Addr: fmt.Sprintf("%s:%d", host, port), Password: c.Password, Username: c.User, DB: redisDBIndex(c), DialTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } if SSLEnabled(c) { tlsCfg, err := tlsConfigFrom(c) if err != nil { return nil, err } opts.TLSConfig = tlsCfg } return opts, nil } // openRedis 按 redisMode 建 UniversalClient(单机 / Sentinel / Cluster)。 func openRedis(c ConnReq) (redis.UniversalClient, error) { mode := normalizeRedisMode(c) switch mode { case "sentinel": addrs := parseRedisAddrs(c.RedisNodes) if len(addrs) == 0 { return nil, fmt.Errorf("Sentinel 模式请填写至少一个 Sentinel 节点(host:port)") } master := strings.TrimSpace(c.MasterName) if master == "" { return nil, fmt.Errorf("Sentinel 模式请填写主节点名 masterName") } opts := &redis.FailoverOptions{ MasterName: master, SentinelAddrs: addrs, Password: c.Password, Username: c.User, SentinelPassword: c.SentinelPassword, DB: redisDBIndex(c), DialTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } if SSLEnabled(c) { tlsCfg, err := tlsConfigFrom(c) if err != nil { return nil, err } opts.TLSConfig = tlsCfg } return redis.NewFailoverClient(opts), nil case "cluster": addrs := parseRedisAddrs(c.RedisNodes) if len(addrs) == 0 { return nil, fmt.Errorf("Cluster 模式请填写至少一个种子节点(host:port)") } opts := &redis.ClusterOptions{ Addrs: addrs, Password: c.Password, Username: c.User, DialTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } if SSLEnabled(c) { tlsCfg, err := tlsConfigFrom(c) if err != nil { return nil, err } opts.TLSConfig = tlsCfg } return redis.NewClusterClient(opts), nil default: opts, err := redisStandaloneOpts(c) if err != nil { return nil, err } return redis.NewClient(opts), nil } } // redisClientFor:会话请求复用会话级单例,非会话请求新建并随请求关闭。 func redisClientFor(c ConnReq) (cli redis.UniversalClient, done func(), err error) { if c.SessionID != "" { s, err := GetSession(c.SessionID) if err != nil { return nil, nil, err } // 请求携带的 Database 决定用哪个 DB 的客户端(对象树切库时逐请求变化) cli, err := s.redisClientFor(c) if err != nil { return nil, nil, err } return cli, func() {}, nil } cli, err = openRedis(c) if err != nil { return nil, nil, err } return cli, func() { _ = cli.Close() }, nil } func reqCtxRedis(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) { if parent == nil { parent = context.Background() } return context.WithTimeout(parent, d) } func testRedis(c ConnReq) error { cli, err := openRedis(c) if err != nil { return err } defer cli.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return cli.Ping(ctx).Err() } func listDatabasesRedis(parent context.Context, c ConnReq) ([]string, error) { // INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换,先 Ping 探活 cli, done, err := redisClientFor(c) if err != nil { return nil, err } defer done() ctx, cancel := reqCtxRedis(parent, 5*time.Second) defer cancel() if err := cli.Ping(ctx).Err(); err != nil { return nil, err } // Cluster 不支持 SELECT 多库,只暴露 0 if normalizeRedisMode(c) == "cluster" { return []string{"0"}, nil } out := make([]string, 16) for i := 0; i < 16; i++ { out[i] = strconv.Itoa(i) } return out, nil } func listTablesRedis(parent context.Context, c ConnReq) ([]string, error) { cli, done, err := redisClientFor(c) if err != nil { return nil, err } defer done() ctx, cancel := reqCtxRedis(parent, 8*time.Second) defer cancel() var keys []string var cursor uint64 for { batch, next, err := cli.Scan(ctx, cursor, "*", 200).Result() if err != nil { return nil, err } keys = append(keys, batch...) cursor = next if cursor == 0 || len(keys) >= 500 { break } } if keys == nil { keys = []string{} } return keys, nil } // redisDangerCmds 需要 confirmDanger 确认后才执行的命令组: // FLUSHALL/FLUSHDB/CONFIG 破坏性;KEYS 大库阻塞。 var redisDangerCmds = map[string]bool{ "FLUSHALL": true, "FLUSHDB": true, "CONFIG": true, "KEYS": true, } func redisOK(start time.Time) ExecResult { return ExecResult{ Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": 1}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds(), } } func redisAffected(start time.Time, n int64) ExecResult { return ExecResult{ Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds(), } } func redisErr(start time.Time, err error) ExecResult { return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } func redisUsage(start time.Time, usage string) ExecResult { return ExecResult{Error: usage, TimeMs: time.Since(start).Milliseconds()} } func execRedis(parent context.Context, c ConnReq) ExecResult { start := time.Now() sql := strings.TrimSpace(c.SQL) if sql == "" { return ExecResult{Error: "empty sql", TimeMs: 0} } fields := splitRedisCmd(sql) if len(fields) == 0 { return ExecResult{Error: "empty command", TimeMs: time.Since(start).Milliseconds()} } cmd := strings.ToUpper(fields[0]) args := fields[1:] // 危险命令二段确认:未确认不执行 if redisDangerCmds[cmd] && !c.ConfirmDanger { return ExecResult{ Error: "检测到危险命令: " + cmd, NeedConfirm: true, TimeMs: time.Since(start).Milliseconds(), } } cli, done, err := redisClientFor(c) if err != nil { return redisErr(start, err) } defer done() ctx, cancel := reqCtxRedis(parent, 10*time.Second) defer cancel() switch cmd { case "PING": val, err := cli.Ping(ctx).Result() if err != nil { return redisErr(start, err) } return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "GET": if len(args) < 1 { return redisUsage(start, "GET key") } val, err := cli.Get(ctx, args[0]).Result() if err == redis.Nil { return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: time.Since(start).Milliseconds()} } if err != nil { return redisErr(start, err) } return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{{"key": args[0], "value": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "SET": // 参数已按 shell 词法切分,value 即 args[1](不再 Join 破坏引号分组); // 支持可选 EX 秒数:SET key value [EX n] if len(args) < 2 { return redisUsage(start, "SET key value [EX seconds]") } exp := time.Duration(0) if len(args) >= 4 && strings.ToUpper(args[2]) == "EX" { n, err := strconv.Atoi(args[3]) if err != nil || n <= 0 { return redisUsage(start, "SET key value EX seconds(秒数需为正整数)") } exp = time.Duration(n) * time.Second } if err := cli.Set(ctx, args[0], args[1], exp).Err(); err != nil { return redisErr(start, err) } return redisOK(start) case "DEL": if len(args) < 1 { return redisUsage(start, "DEL key [key...]") } n, err := cli.Del(ctx, args...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "KEYS": // 危险组已在上面对 confirmDanger 把关,此处直接执行 pat := "*" if len(args) > 0 { pat = args[0] } keys, err := cli.Keys(ctx, pat).Result() if err != nil { return redisErr(start, err) } rows := make([]map[string]interface{}, 0, len(keys)) for _, k := range keys { rows = append(rows, map[string]interface{}{"key": k}) } return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()} case "FLUSHALL", "FLUSHDB": // 危险组,已经过 confirmDanger 把关 var err error if cmd == "FLUSHALL" { err = cli.FlushAll(ctx).Err() } else { err = cli.FlushDB(ctx).Err() } if err != nil { return redisErr(start, err) } return redisOK(start) case "CONFIG": // 危险组,已经过 confirmDanger 把关;通用透传(CONFIG GET/SET ...) if len(args) < 1 { return redisUsage(start, "CONFIG GET|SET parameter [value]") } doArgs := append([]interface{}{"CONFIG"}, strsToAnys(args)...) val, err := cli.Do(ctx, doArgs...).Result() if err != nil { return redisErr(start, err) } return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": normalize(fmt.Sprintf("%v", val))}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "SCAN": pat := "*" count := int64(200) i := 0 if len(args) > 0 { // SCAN cursor [MATCH pattern] [COUNT n] —— 忽略 cursor,内部自扫 i = 1 } for i < len(args) { up := strings.ToUpper(args[i]) if up == "MATCH" && i+1 < len(args) { pat = args[i+1] i += 2 continue } if up == "COUNT" && i+1 < len(args) { if n, err := strconv.ParseInt(args[i+1], 10, 64); err == nil && n > 0 { count = n } i += 2 continue } i++ } names, truncated, err := scanAll(ctx, cli, pat, count, redisKeyListCap) if err != nil { return redisErr(start, err) } if names == nil { names = []string{} } rows := make([]map[string]interface{}, 0, len(names)) for _, k := range names { rows = append(rows, map[string]interface{}{"key": k}) } return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds(), Truncated: truncated} case "EXPIRE": if len(args) < 2 { return redisUsage(start, "EXPIRE key seconds") } sec, err := strconv.Atoi(args[1]) if err != nil { return redisUsage(start, "EXPIRE key seconds(秒数需为整数)") } ok, err := cli.Expire(ctx, args[0], time.Duration(sec)*time.Second).Result() if err != nil { return redisErr(start, err) } if !ok { return redisAffected(start, 0) } return redisAffected(start, 1) case "PERSIST": if len(args) < 1 { return redisUsage(start, "PERSIST key") } ok, err := cli.Persist(ctx, args[0]).Result() if err != nil { return redisErr(start, err) } if !ok { return redisAffected(start, 0) } return redisAffected(start, 1) case "HSET": if len(args) < 3 || len(args)%2 == 0 { return redisUsage(start, "HSET key field value [field value...]") } n, err := cli.HSet(ctx, args[0], strsToAnys(args[1:])...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "HDEL": if len(args) < 2 { return redisUsage(start, "HDEL key field [field...]") } n, err := cli.HDel(ctx, args[0], args[1:]...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "SADD": if len(args) < 2 { return redisUsage(start, "SADD key member [member...]") } n, err := cli.SAdd(ctx, args[0], strsToAnys(args[1:])...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "SREM": if len(args) < 2 { return redisUsage(start, "SREM key member [member...]") } n, err := cli.SRem(ctx, args[0], strsToAnys(args[1:])...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "ZADD": // ZADD key score member [score member...] if len(args) < 3 || len(args)%2 == 0 { return redisUsage(start, "ZADD key score member [score member...]") } var zs []redis.Z for i := 1; i+1 < len(args); i += 2 { score, err := strconv.ParseFloat(args[i], 64) if err != nil { return redisUsage(start, "ZADD score 需为数字: "+args[i]) } zs = append(zs, redis.Z{Score: score, Member: args[i+1]}) } n, err := cli.ZAdd(ctx, args[0], zs...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "ZREM": if len(args) < 2 { return redisUsage(start, "ZREM key member [member...]") } n, err := cli.ZRem(ctx, args[0], strsToAnys(args[1:])...).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "LPUSH", "RPUSH": if len(args) < 2 { return redisUsage(start, cmd+" key value [value...]") } var n int64 var err error if cmd == "LPUSH" { n, err = cli.LPush(ctx, args[0], strsToAnys(args[1:])...).Result() } else { n, err = cli.RPush(ctx, args[0], strsToAnys(args[1:])...).Result() } if err != nil { return redisErr(start, err) } return redisAffected(start, n) case "LSET": if len(args) < 3 { return redisUsage(start, "LSET key index value") } idx, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return redisUsage(start, "LSET key index value(index 需为整数)") } if err := cli.LSet(ctx, args[0], idx, args[2]).Err(); err != nil { return redisErr(start, err) } return redisOK(start) case "INCR", "DECR": if len(args) < 1 { return redisUsage(start, cmd+" key") } var n int64 var err error if cmd == "INCR" { n, err = cli.Incr(ctx, args[0]).Result() } else { n, err = cli.Decr(ctx, args[0]).Result() } if err != nil { return redisErr(start, err) } return ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{{"value": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "MGET": if len(args) < 1 { return redisUsage(start, "MGET key [key...]") } vals, err := cli.MGet(ctx, args...).Result() if err != nil { return redisErr(start, err) } rows := make([]map[string]interface{}, 0, len(args)) for i, v := range vals { rows = append(rows, map[string]interface{}{"key": args[i], "value": normalize(v)}) } return ExecResult{Columns: []string{"key", "value"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()} case "MSET": if len(args) < 2 || len(args)%2 != 0 { return redisUsage(start, "MSET key value [key value...]") } if err := cli.MSet(ctx, strsToAnys(args)...).Err(); err != nil { return redisErr(start, err) } return redisOK(start) case "RENAME": if len(args) < 2 { return redisUsage(start, "RENAME key newkey") } if err := cli.Rename(ctx, args[0], args[1]).Err(); err != nil { return redisErr(start, err) } return redisOK(start) case "HGETALL": if len(args) < 1 { return redisUsage(start, "HGETALL key") } c2 := c c2.Key = args[0] c2.Limit = redisValueRowCap v := GetRedisKey(ctx, c2) if v.Error != "" && v.Type == "" { return ExecResult{Error: v.Error, TimeMs: time.Since(start).Milliseconds()} } return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "LRANGE": if len(args) < 1 { return redisUsage(start, "LRANGE key [start stop]") } c2 := c c2.Key = args[0] c2.Limit = redisValueRowCap v := GetRedisKey(ctx, c2) return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "SMEMBERS": if len(args) < 1 { return redisUsage(start, "SMEMBERS key") } c2 := c c2.Key = args[0] v := GetRedisKey(ctx, c2) return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "ZRANGE": if len(args) < 1 { return redisUsage(start, "ZRANGE key [start stop]") } c2 := c c2.Key = args[0] v := GetRedisKey(ctx, c2) return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, NextCursor: v.NextCursor, Error: v.Error} case "TYPE": if len(args) < 1 { return redisUsage(start, "TYPE key") } t, err := cli.Type(ctx, args[0]).Result() if err != nil { return redisErr(start, err) } return ExecResult{Columns: []string{"key", "type"}, Rows: []map[string]interface{}{{"key": args[0], "type": t}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} case "TTL": if len(args) < 1 { return redisUsage(start, "TTL key") } n, err := cli.TTL(ctx, args[0]).Result() if err != nil { return redisErr(start, err) } return ExecResult{Columns: []string{"key", "ttl_sec"}, Rows: []map[string]interface{}{{"key": args[0], "ttl_sec": int64(n.Seconds())}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()} default: // 未点名的命令走通用 Do,避免 CLI 只能跑白名单。 doArgs := append([]interface{}{cmd}, strsToAnys(args)...) val, err := cli.Do(ctx, doArgs...).Result() if err == redis.Nil { return ExecResult{ Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": "(nil)"}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds(), } } if err != nil { return redisErr(start, err) } return formatRedisReply(start, val) } } func formatRedisReply(start time.Time, val interface{}) ExecResult { ms := time.Since(start).Milliseconds() switch v := val.(type) { case nil: return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": "(nil)"}}, RowCount: 1, TimeMs: ms} case string: return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} case []byte: return formatRedisReply(start, string(v)) case int64: return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} case float64: return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} case bool: return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": v}}, RowCount: 1, TimeMs: ms} case error: return ExecResult{Error: v.Error(), TimeMs: ms} case []interface{}: if len(v) == 0 { return ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: ms} } if kvRows := redisPairRows(v); kvRows != nil { return ExecResult{Columns: []string{"field", "value"}, Rows: kvRows, RowCount: len(kvRows), TimeMs: ms} } rows := make([]map[string]interface{}, 0, len(v)) for i, item := range v { rows = append(rows, map[string]interface{}{"index": i, "value": normalizeRedisReply(item)}) } return ExecResult{Columns: []string{"index", "value"}, Rows: rows, RowCount: len(rows), TimeMs: ms} case map[string]interface{}: keys := make([]string, 0, len(v)) for k := range v { keys = append(keys, k) } sort.Strings(keys) rows := make([]map[string]interface{}, 0, len(keys)) for _, k := range keys { rows = append(rows, map[string]interface{}{"field": k, "value": normalizeRedisReply(v[k])}) } return ExecResult{Columns: []string{"field", "value"}, Rows: rows, RowCount: len(rows), TimeMs: ms} default: return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": normalizeRedisReply(v)}}, RowCount: 1, TimeMs: ms} } } func redisPairRows(v []interface{}) []map[string]interface{} { if len(v) < 2 || len(v)%2 != 0 { return nil } for _, item := range v { switch item.(type) { case string, []byte: default: return nil } } rows := make([]map[string]interface{}, 0, len(v)/2) for i := 0; i+1 < len(v); i += 2 { rows = append(rows, map[string]interface{}{ "field": normalizeRedisReply(v[i]), "value": normalizeRedisReply(v[i+1]), }) } return rows } func normalizeRedisReply(v interface{}) interface{} { switch t := v.(type) { case nil: return "(nil)" case []byte: return string(t) case []interface{}: parts := make([]string, len(t)) for i, x := range t { parts[i] = fmt.Sprintf("%v", normalizeRedisReply(x)) } return strings.Join(parts, " ") default: return normalize(t) } } func strsToAnys(ss []string) []interface{} { out := make([]interface{}, len(ss)) for i, s := range ss { out[i] = s } return out } // splitRedisCmd 按 shell 词法切分:空格分隔,单/双引号分组,反斜杠转义。 func splitRedisCmd(s string) []string { var out []string var cur strings.Builder inS, inD := false, false esc := false for _, r := range s { switch { case esc: cur.WriteRune(r) esc = false case r == '\\': esc = true case r == '"' && !inS: inD = !inD case r == '\'' && !inD: inS = !inS case !inS && !inD && (r == ' ' || r == '\t' || r == '\n'): if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } default: cur.WriteRune(r) } } if cur.Len() > 0 { out = append(out, cur.String()) } return out } func isRedis(d string) bool { return driverKey(d) == "redis" } // IsRedisDriver exported for router guards. func IsRedisDriver(d string) bool { return isRedis(d) } func listColumnsRedis(parent context.Context, c ConnReq, key string) ([]ColumnInfo, error) { cli, done, err := redisClientFor(c) if err != nil { return nil, err } defer done() ctx, cancel := reqCtxRedis(parent, 5*time.Second) defer cancel() typ, err := cli.Type(ctx, key).Result() if err != nil { return nil, err } ttl, _ := cli.TTL(ctx, key).Result() return []ColumnInfo{ {Name: key, Type: typ, Comment: fmt.Sprintf("ttl=%ds", int64(ttl.Seconds()))}, }, nil } const redisKeyListCap = 500 const redisValueRowCap = 500 func redisKeyName(c ConnReq) string { if c.Key != "" { return c.Key } return c.Table } func redisScanPattern(c ConnReq) string { p := strings.TrimSpace(c.Pattern) if p == "" { return "*" } return p } func redisValueLimit(c ConnReq) int { n := c.Limit if n <= 0 { n = redisValueRowCap } if n > MaxRowLimit { n = MaxRowLimit } return n } func ttlSeconds(d time.Duration) int64 { // go-redis maps Redis TTL -1 (no expire) / -2 (missing) to -1ns/-2ns // (some versions use -1s/-2s). Truncating with d/time.Second turns those into 0. switch d { case time.Duration(-1), -1 * time.Second: return -1 case time.Duration(-2), -2 * time.Second: return -2 } return int64(d / time.Second) } // scanAll 全量 SCAN 到 limit 截断。集群模式遍历所有 master 分片 —— // ClusterClient.Scan 只命中单个节点,会漏掉其他分片的键。 func scanAll(ctx context.Context, cli redis.UniversalClient, pat string, count int64, limit int) (names []string, truncated bool, err error) { scanOne := func(c scanClient) error { var cursor uint64 for { batch, next, err := c.Scan(ctx, cursor, pat, count).Result() if err != nil { return err } names = append(names, batch...) cursor = next if len(names) >= limit { truncated = true return nil } if cursor == 0 { return nil } } } if cc, ok := cli.(*redis.ClusterClient); ok { err = cc.ForEachMaster(ctx, func(ctx context.Context, mc *redis.Client) error { return scanOne(mc) }) if err == nil { // 跨分片归并排序,保证键树稳定 sort.Strings(names) if len(names) > limit { names = names[:limit] } } return names, truncated, err } err = scanOne(cli) return names, truncated, err } // scanClient Scan 的最小接口(*redis.Client 与 UniversalClient 都满足) type scanClient interface { Scan(ctx context.Context, cursor uint64, match string, count int64) *redis.ScanCmd } // ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500. func ListRedisKeys(parent context.Context, c ConnReq) RedisKeysResult { start := time.Now() cli, done, err := redisClientFor(c) if err != nil { return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } defer done() ctx, cancel := reqCtxRedis(parent, 12*time.Second) defer cancel() pat := redisScanPattern(c) names, truncated, err := scanAll(ctx, cli, pat, 200, redisKeyListCap) if err != nil { return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } if names == nil { names = []string{} } out := make([]RedisKeyInfo, len(names)) if len(names) == 0 { return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()} } // Pipeline TYPE + TTL (avoid N+1) pipe := cli.Pipeline() typeCmds := make([]*redis.StatusCmd, len(names)) ttlCmds := make([]*redis.DurationCmd, len(names)) for i, k := range names { typeCmds[i] = pipe.Type(ctx, k) ttlCmds[i] = pipe.TTL(ctx, k) } _, _ = pipe.Exec(ctx) for i, k := range names { typ := "none" if t, err := typeCmds[i].Result(); err == nil { typ = t } ttl := int64(-2) if d, err := ttlCmds[i].Result(); err == nil { ttl = ttlSeconds(d) } out[i] = RedisKeyInfo{Name: k, Type: typ, TTL: ttl} } return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()} } // GetRedisKey typed value → ResultPane columns/rows. func GetRedisKey(parent context.Context, c ConnReq) RedisKeyValue { start := time.Now() key := redisKeyName(c) if key == "" { return RedisKeyValue{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()} } cli, done, err := redisClientFor(c) if err != nil { return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } defer done() ctx, cancel := reqCtxRedis(parent, 10*time.Second) defer cancel() typ, err := cli.Type(ctx, key).Result() if err != nil { return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()} } ttl := int64(-2) if d, err := cli.TTL(ctx, key).Result(); err == nil { ttl = ttlSeconds(d) } limit := redisValueLimit(c) base := RedisKeyValue{Name: key, Type: typ, TTL: ttl, Limit: limit} switch typ { case "none": base.Columns = []string{"value"} base.Rows = []map[string]interface{}{} base.RowCount = 0 base.Error = "key 不存在" base.TimeMs = time.Since(start).Milliseconds() return base case "string": val, err := cli.Get(ctx, key).Result() if err == redis.Nil { base.Columns = []string{"value"} base.Rows = []map[string]interface{}{} base.RowCount = 0 base.TimeMs = time.Since(start).Milliseconds() return base } if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } base.Columns = []string{"value"} base.Rows = []map[string]interface{}{{"value": val}} base.RowCount = 1 base.TimeMs = time.Since(start).Milliseconds() return base case "hash": // HSCAN 游标分页:cursor 回传续载(0=起点/到底),页内按 field 排序保持观感 pairs, next, err := cli.HScan(ctx, key, uint64(c.Cursor), "*", int64(limit)).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } base.Columns = []string{"field", "value"} rows := make([]map[string]interface{}, 0, len(pairs)/2) for i := 0; i+1 < len(pairs); i += 2 { rows = append(rows, map[string]interface{}{"field": pairs[i], "value": pairs[i+1]}) } sort.Slice(rows, func(i, j int) bool { return fmt.Sprint(rows[i]["field"]) < fmt.Sprint(rows[j]["field"]) }) base.Rows = rows base.RowCount = len(rows) base.NextCursor = int64(next) base.Truncated = next != 0 base.TimeMs = time.Since(start).Milliseconds() return base case "list": // 偏移分页:cursor = 起始下标 offset := c.Cursor if offset < 0 { offset = 0 } vals, err := cli.LRange(ctx, key, offset, offset+int64(limit)-1).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } n, _ := cli.LLen(ctx, key).Result() base.Columns = []string{"index", "value"} rows := make([]map[string]interface{}, 0, len(vals)) for i, v := range vals { rows = append(rows, map[string]interface{}{"index": offset + int64(i), "value": v}) } base.Rows = rows base.RowCount = len(rows) if offset+int64(len(vals)) < n { base.NextCursor = offset + int64(len(vals)) base.Truncated = true } base.TimeMs = time.Since(start).Milliseconds() return base case "set": // SSCAN 游标分页:每页 limit,cursor 回传续载 batch, next, err := cli.SScan(ctx, key, uint64(c.Cursor), "*", int64(limit)).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } base.Columns = []string{"member"} rows := make([]map[string]interface{}, 0, len(batch)) for _, m := range batch { rows = append(rows, map[string]interface{}{"member": m}) } base.Rows = rows base.RowCount = len(rows) base.NextCursor = int64(next) base.Truncated = next != 0 base.TimeMs = time.Since(start).Milliseconds() return base case "zset": // 偏移分页(按 score 升序):cursor = 起始排名 zoffset := c.Cursor if zoffset < 0 { zoffset = 0 } vals, err := cli.ZRangeWithScores(ctx, key, zoffset, zoffset+int64(limit)-1).Result() if err != nil { base.Error = errMsg(err) base.TimeMs = time.Since(start).Milliseconds() return base } n, _ := cli.ZCard(ctx, key).Result() if zoffset+int64(len(vals)) < n { base.NextCursor = zoffset + int64(len(vals)) base.Truncated = true } base.Columns = []string{"score", "member"} rows := make([]map[string]interface{}, 0, len(vals)) for _, z := range vals { rows = append(rows, map[string]interface{}{"score": z.Score, "member": z.Member}) } base.Rows = rows base.RowCount = len(rows) base.TimeMs = time.Since(start).Milliseconds() return base default: // stream / recon / module types: meta only base.Columns = []string{"info"} base.Rows = []map[string]interface{}{{"info": fmt.Sprintf("类型 %s 暂不展开,可用命令查看", typ)}} base.RowCount = 1 base.TimeMs = time.Since(start).Milliseconds() return base } } // DeleteRedisKey DEL one key. func DeleteRedisKey(parent context.Context, c ConnReq) ExecResult { start := time.Now() key := redisKeyName(c) if key == "" { return ExecResult{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()} } cli, done, err := redisClientFor(c) if err != nil { return redisErr(start, err) } defer done() ctx, cancel := reqCtxRedis(parent, 5*time.Second) defer cancel() n, err := cli.Del(ctx, key).Result() if err != nil { return redisErr(start, err) } return redisAffected(start, n) } // RedisDelPatternResult 按模式批量删除(目录删除)结果。 type RedisDelPatternResult struct { Deleted int `json:"deleted"` Scanned int `json:"scanned"` Truncated bool `json:"truncated"` Error string `json:"error"` TimeMs int64 `json:"timeMs"` } // 目录删除安全上限:超出即拒绝,防止误配 pattern 造成删库风暴。 const redisDelPatternCap = 10000 // DeleteRedisPattern 按模式批量删除键(SCAN + UNLINK 分批)。用于「删除目录」: // 前端传 `目录前缀:*`。拒绝全库 `*`;扫描量超上限拒绝。 func DeleteRedisPattern(parent context.Context, c ConnReq) RedisDelPatternResult { start := time.Now() fail := func(msg string) RedisDelPatternResult { return RedisDelPatternResult{Error: msg, TimeMs: time.Since(start).Milliseconds()} } pat := redisScanPattern(c) if pat == "*" || pat == "" { return fail("拒绝全库模式删除(*),请指定目录前缀") } cli, done, err := redisClientFor(c) if err != nil { return fail(errMsg(err)) } defer done() ctx, cancel := reqCtxRedis(parent, 60*time.Second) defer cancel() names, truncated, err := scanAll(ctx, cli, pat, 500, redisDelPatternCap+1) if err != nil { return fail(errMsg(err)) } if len(names) > redisDelPatternCap { return fail(fmt.Sprintf("匹配键 %d 个超过上限 %d,请缩小目录范围", len(names), redisDelPatternCap)) } total := 0 for i := 0; i < len(names); i += 500 { end := i + 500 if end > len(names) { end = len(names) } n, err := cli.Unlink(ctx, names[i:end]...).Result() if err != nil { return RedisDelPatternResult{ Deleted: total, Scanned: len(names), Truncated: truncated, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds(), } } total += int(n) } return RedisDelPatternResult{ Deleted: total, Scanned: len(names), Truncated: truncated, TimeMs: time.Since(start).Milliseconds(), } }