🐛 fix(redis): 修复超过16个数据库无法展示

- 后端改为通过 CONFIG GET databases 动态获取 Redis 数据库数量
- 放宽单机和 Sentinel 模式的 RedisDB 索引限制,支持 db16 及以上
- 前端连接配置和持久化不再裁剪高编号 Redis 数据库
- 连接测试成功后按服务端返回的真实数据库列表展示可选 DB
- 增加 Redis db31 展示、切换、保存和 URI 解析回归测试
Refs #558
This commit is contained in:
Syngnat
2026-06-12 17:22:09 +08:00
parent 70134cd77f
commit 8519748512
12 changed files with 369 additions and 78 deletions

View File

@@ -28,7 +28,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
runConfig.Database = name
case "redis":
runConfig.Database = name
if idx, err := strconv.Atoi(name); err == nil && idx >= 0 && idx <= 15 {
if idx, err := strconv.Atoi(name); err == nil && idx >= 0 {
runConfig.RedisDB = idx
}
default:

View File

@@ -229,6 +229,19 @@ func TestNormalizeRunConfig_IRISUsesNamespaceFromTree(t *testing.T) {
}
}
func TestNormalizeRunConfig_RedisAllowsDatabaseIndexAboveDefault(t *testing.T) {
t.Parallel()
runConfig := normalizeRunConfig(connection.ConnectionConfig{
Type: "redis",
RedisDB: 0,
}, "31")
if runConfig.Database != "31" || runConfig.RedisDB != 31 {
t.Fatalf("expected Redis db31 from tree, got database=%q redisDB=%d", runConfig.Database, runConfig.RedisDB)
}
}
func TestNormalizeSchemaAndTable_IRISDoesNotTreatNamespaceAsSchema(t *testing.T) {
t.Parallel()

View File

@@ -12,7 +12,7 @@ type RedisValue struct {
// RedisDBInfo represents information about a Redis database
type RedisDBInfo struct {
Index int `json:"index"` // Database index (single: 0-15, cluster: logical 0-15)
Index int `json:"index"` // Database index (single/sentinel: server configured range, cluster: logical 0-15)
Keys int64 `json:"keys"` // Number of keys in this database
}

View File

@@ -37,6 +37,8 @@ type RedisClientImpl struct {
}
const (
redisDefaultDatabaseCount = 16
redisClusterLogicalDBCount = 16
redisScanDefaultTargetCount int64 = 2000
redisScanMaxTargetCount int64 = 10000
redisScanMinStepCount int64 = 200
@@ -263,10 +265,9 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
config.Password = sanitizeRedisPassword(config.Password)
config.RedisSentinelPassword = sanitizeRedisPassword(config.RedisSentinelPassword)
r.config = config
if r.config.RedisDB < 0 || r.config.RedisDB > 15 {
if r.config.RedisDB < 0 {
r.config.RedisDB = 0
}
r.currentDB = r.config.RedisDB
r.forwarder = nil
r.client = nil
r.singleClient = nil
@@ -282,6 +283,10 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error {
topology := strings.ToLower(strings.TrimSpace(config.Topology))
isSentinel := topology == "sentinel"
r.isCluster = !isSentinel && (topology == "cluster" || len(seedAddrs) > 1)
if r.isCluster && r.config.RedisDB >= redisClusterLogicalDBCount {
r.config.RedisDB = 0
}
r.currentDB = r.config.RedisDB
if (r.isCluster || isSentinel) && config.UseSSH {
return fmt.Errorf("Redis %s模式暂不支持 SSH 隧道,请关闭 SSH 后重试", redisTopologyDisplayName(topology))
@@ -1349,8 +1354,8 @@ func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
if err != nil {
return nil, fmt.Errorf("无效数据库索引: %s", args[1])
}
if index < 0 || index > 15 {
return nil, fmt.Errorf("数据库索引必须在 0-15 之间")
if index < 0 || index >= redisClusterLogicalDBCount {
return nil, fmt.Errorf("数据库索引必须在 0-%d 之间", redisClusterLogicalDBCount-1)
}
r.currentDB = index
r.config.RedisDB = index
@@ -1496,6 +1501,67 @@ func (r *RedisClientImpl) GetServerInfo() (map[string]string, error) {
return result, nil
}
func parseRedisKeyspaceDatabaseKeys(info string) map[int]int64 {
dbMap := make(map[int]int64)
lines := strings.Split(info, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "db") {
// Format: db0:keys=123,expires=0,avg_ttl=0
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
dbIndex, err := strconv.Atoi(strings.TrimPrefix(parts[0], "db"))
if err != nil {
continue
}
kvPairs := strings.Split(parts[1], ",")
for _, kv := range kvPairs {
if strings.HasPrefix(kv, "keys=") {
keys, _ := strconv.ParseInt(strings.TrimPrefix(kv, "keys="), 10, 64)
dbMap[dbIndex] = keys
break
}
}
}
}
return dbMap
}
func parseRedisConfiguredDatabaseCount(config map[string]string) (int, bool) {
for key, value := range config {
if !strings.EqualFold(strings.TrimSpace(key), "databases") {
continue
}
count, err := strconv.Atoi(strings.TrimSpace(value))
if err == nil && count > 0 {
return count, true
}
}
return 0, false
}
func (r *RedisClientImpl) resolveRedisDatabaseCount(ctx context.Context, dbMap map[int]int64) int {
count := redisDefaultDatabaseCount
if r.currentDB >= count {
count = r.currentDB + 1
}
for index := range dbMap {
if index >= count {
count = index + 1
}
}
config, err := r.client.ConfigGet(ctx, "databases").Result()
if err != nil {
return count
}
if configured, ok := parseRedisConfiguredDatabaseCount(config); ok && configured > count {
count = configured
}
return count
}
// GetDatabases returns information about all databases
func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
if r.client == nil {
@@ -1521,8 +1587,8 @@ func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
logger.Warnf("Redis 集群获取 key 数量失败,回退为 0: %v", err)
totalKeys = 0
}
result := make([]RedisDBInfo, 16)
for i := 0; i < 16; i++ {
result := make([]RedisDBInfo, redisClusterLogicalDBCount)
for i := 0; i < redisClusterLogicalDBCount; i++ {
result[i] = RedisDBInfo{Index: i, Keys: 0}
}
result[0].Keys = totalKeys
@@ -1535,36 +1601,10 @@ func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) {
return nil, err
}
// Parse keyspace info
dbMap := make(map[int]int64)
lines := strings.Split(info, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "db") {
// Format: db0:keys=123,expires=0,avg_ttl=0
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
dbIndex, err := strconv.Atoi(strings.TrimPrefix(parts[0], "db"))
if err != nil {
continue
}
// Parse keys count
kvPairs := strings.Split(parts[1], ",")
for _, kv := range kvPairs {
if strings.HasPrefix(kv, "keys=") {
keys, _ := strconv.ParseInt(strings.TrimPrefix(kv, "keys="), 10, 64)
dbMap[dbIndex] = keys
break
}
}
}
}
// Return all 16 databases (0-15)
result := make([]RedisDBInfo, 16)
for i := 0; i < 16; i++ {
dbMap := parseRedisKeyspaceDatabaseKeys(info)
databaseCount := r.resolveRedisDatabaseCount(ctx, dbMap)
result := make([]RedisDBInfo, databaseCount)
for i := 0; i < databaseCount; i++ {
result[i] = RedisDBInfo{
Index: i,
Keys: dbMap[i], // Will be 0 if not in map
@@ -1581,16 +1621,16 @@ func (r *RedisClientImpl) SelectDB(index int) error {
}
if r.isCluster {
if index < 0 || index > 15 {
return fmt.Errorf("数据库索引必须在 0-15 之间")
if index < 0 || index >= redisClusterLogicalDBCount {
return fmt.Errorf("数据库索引必须在 0-%d 之间", redisClusterLogicalDBCount-1)
}
r.currentDB = index
r.config.RedisDB = index
return nil
}
if index < 0 || index > 15 {
return fmt.Errorf("数据库索引必须在 0-15 之间")
if index < 0 {
return fmt.Errorf("数据库索引必须大于等于 0")
}
nextConfig := r.config

View File

@@ -2,17 +2,101 @@ package redis
import (
"GoNavi-Wails/internal/connection"
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/big"
"net"
"sort"
"strconv"
"strings"
"testing"
goredis "github.com/redis/go-redis/v9"
)
func startRedisProtocolTestServer(t *testing.T, handler func([]string) string) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen redis test server failed: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
args, err := readRedisProtocolArray(reader)
if err != nil {
return
}
response := handler(args)
if response == "" {
response = "+OK\r\n"
}
if _, err := conn.Write([]byte(response)); err != nil {
return
}
}
}()
}
}()
return listener.Addr().String()
}
func readRedisProtocolArray(reader *bufio.Reader) ([]string, error) {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "*") {
return nil, fmt.Errorf("expected array, got %q", line)
}
count, err := strconv.Atoi(strings.TrimPrefix(line, "*"))
if err != nil {
return nil, err
}
args := make([]string, 0, count)
for i := 0; i < count; i++ {
bulkHeader, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
bulkHeader = strings.TrimSpace(bulkHeader)
if !strings.HasPrefix(bulkHeader, "$") {
return nil, fmt.Errorf("expected bulk string, got %q", bulkHeader)
}
size, err := strconv.Atoi(strings.TrimPrefix(bulkHeader, "$"))
if err != nil {
return nil, err
}
buf := make([]byte, size+2)
if _, err := io.ReadFull(reader, buf); err != nil {
return nil, err
}
args = append(args, string(buf[:size]))
}
return args, nil
}
func redisBulkString(value string) string {
return fmt.Sprintf("$%d\r\n%s\r\n", len(value), value)
}
// 回归保护HGETALL 在 RESP3 下返回 map[interface{}]interface{}go-redis v9 默认 RESP3
// 这种类型 encoding/json 无法序列化,原值穿透到 Wails RPC 会让 Windows 进程退出(用户感知闪退)。
// formatCommandResult 必须把 map 平展成交错 [k1, v1, k2, v2, ...] array前端按 array 渲染。
@@ -277,6 +361,47 @@ func TestRedisClusterKeepsSSHValidation(t *testing.T) {
}
}
func TestRedisGetDatabasesUsesConfiguredDatabaseCountAboveDefault(t *testing.T) {
keyspaceInfo := "# Keyspace\r\ndb0:keys=1,expires=0,avg_ttl=0\r\ndb31:keys=2,expires=0,avg_ttl=0\r\n"
addr := startRedisProtocolTestServer(t, func(args []string) string {
command := strings.ToUpper(strings.TrimSpace(args[0]))
switch command {
case "HELLO":
return "-ERR unknown command 'HELLO'\r\n"
case "CLIENT":
return "-ERR unknown subcommand\r\n"
case "CONFIG":
if len(args) >= 3 && strings.EqualFold(args[1], "GET") && strings.EqualFold(args[2], "databases") {
return "*2\r\n$9\r\ndatabases\r\n$2\r\n32\r\n"
}
case "INFO":
return redisBulkString(keyspaceInfo)
}
return "+OK\r\n"
})
rawClient := goredis.NewClient(&goredis.Options{
Addr: addr,
Protocol: 2,
})
client := &RedisClientImpl{
client: rawClient,
singleClient: rawClient,
}
defer client.Close()
dbs, err := client.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases returned error: %v", err)
}
if len(dbs) != 32 {
t.Fatalf("expected 32 redis databases, got %d (%#v)", len(dbs), dbs)
}
if dbs[31].Index != 31 || dbs[31].Keys != 2 {
t.Fatalf("expected db31 with 2 keys, got %#v", dbs[31])
}
}
func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) {
oldConnect := redisDBSwitchConnect
defer func() {
@@ -317,12 +442,12 @@ func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) {
}
defer client.Close()
if err := client.SelectDB(6); err != nil {
if err := client.SelectDB(31); err != nil {
t.Fatalf("SelectDB returned error: %v", err)
}
if captured.RedisDB != 6 || client.currentDB != 6 {
t.Fatalf("expected RedisDB/currentDB=6, captured=%d current=%d", captured.RedisDB, client.currentDB)
if captured.RedisDB != 31 || client.currentDB != 31 {
t.Fatalf("expected RedisDB/currentDB=31, captured=%d current=%d", captured.RedisDB, client.currentDB)
}
if captured.Topology != "sentinel" {
t.Fatalf("expected sentinel topology to be preserved, got %q", captured.Topology)