package redis import ( "context" "crypto/tls" "errors" "fmt" "math" "math/big" "net" "net/url" "reflect" "strconv" "strings" "sync" "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/logger" "GoNavi-Wails/internal/ssh" "github.com/redis/go-redis/v9" ) var ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期") // RedisClientImpl implements RedisClient using go-redis type RedisClientImpl struct { client redis.UniversalClient singleClient *redis.Client clusterClient *redis.ClusterClient config connection.ConnectionConfig currentDB int isCluster bool seedAddrs []string forwarder *ssh.LocalForwarder } const ( redisScanDefaultTargetCount int64 = 2000 redisScanMaxTargetCount int64 = 10000 redisScanMinStepCount int64 = 200 redisScanMaxStepCount int64 = 2000 redisScanMaxRounds = 64 redisScanMaxDuration = 12 * time.Second redisSearchMaxTargetCount int64 = 1000 redisSearchMaxStepCount int64 = 1000 redisSearchMaxRounds = 16 redisSearchMaxDuration = 3 * time.Second ) // NewRedisClient creates a new Redis client instance func NewRedisClient() RedisClient { return &RedisClientImpl{} } func normalizeRedisTimeout(timeoutSeconds int) time.Duration { if timeoutSeconds <= 0 { return 30 * time.Second } return time.Duration(timeoutSeconds) * time.Second } func normalizeRedisSeedAddress(raw string, defaultPort int) (string, error) { addr := strings.TrimSpace(raw) if addr == "" { return "", fmt.Errorf("Redis 节点地址不能为空") } if _, _, err := net.SplitHostPort(addr); err == nil { return addr, nil } if !strings.Contains(addr, ":") { return net.JoinHostPort(addr, strconv.Itoa(defaultPort)), nil } // 尝试兼容 host:port 但端口格式异常的场景。 host, port, ok := strings.Cut(addr, ":") if !ok { return "", fmt.Errorf("无效 Redis 节点地址: %s", addr) } host = strings.TrimSpace(host) port = strings.TrimSpace(port) if host == "" { return "", fmt.Errorf("无效 Redis 节点地址: %s", addr) } if _, err := strconv.Atoi(port); err != nil { return "", fmt.Errorf("无效 Redis 端口: %s", addr) } return net.JoinHostPort(host, port), nil } func buildRedisSeedAddrs(config connection.ConnectionConfig) ([]string, error) { defaultPort := config.Port if defaultPort <= 0 { defaultPort = 6379 } candidates := make([]string, 0, 1+len(config.Hosts)) if strings.TrimSpace(config.Host) != "" { candidates = append(candidates, fmt.Sprintf("%s:%d", strings.TrimSpace(config.Host), defaultPort)) } candidates = append(candidates, config.Hosts...) seen := make(map[string]struct{}, len(candidates)) addrs := make([]string, 0, len(candidates)) for _, candidate := range candidates { normalized, err := normalizeRedisSeedAddress(candidate, defaultPort) if err != nil { return nil, err } if _, exists := seen[normalized]; exists { continue } seen[normalized] = struct{}{} addrs = append(addrs, normalized) } if len(addrs) == 0 { return nil, fmt.Errorf("Redis 连接地址不能为空") } return addrs, nil } func (r *RedisClientImpl) redisNamespacePrefixForDB(index int) string { if !r.isCluster || index <= 0 { return "" } // Redis Cluster 仅支持物理 db0;这里用固定前缀模拟逻辑库隔离。 return fmt.Sprintf("__gonavi_db_%d__:", index) } func (r *RedisClientImpl) redisNamespacePrefix() string { return r.redisNamespacePrefixForDB(r.currentDB) } func (r *RedisClientImpl) toPhysicalKey(key string) string { trimmed := strings.TrimSpace(key) if trimmed == "" { return "" } prefix := r.redisNamespacePrefix() if prefix == "" || strings.HasPrefix(trimmed, prefix) { return trimmed } return prefix + trimmed } func (r *RedisClientImpl) toPhysicalPattern(pattern string) string { normalized := strings.TrimSpace(pattern) if normalized == "" { normalized = "*" } prefix := r.redisNamespacePrefix() if prefix == "" { return normalized } return prefix + normalized } func redisGlobPatternLiteralKey(pattern string) (string, bool) { if pattern == "" { return "", false } var builder strings.Builder for i := 0; i < len(pattern); i++ { char := pattern[i] if char == '\\' { if i+1 >= len(pattern) { return "", false } i++ builder.WriteByte(pattern[i]) continue } if char == '*' || char == '?' || char == '[' { return "", false } builder.WriteByte(char) } return builder.String(), true } func escapeRedisGlobLiteral(value string) string { var builder strings.Builder for i := 0; i < len(value); i++ { char := value[i] if char == '*' || char == '?' || char == '[' || char == ']' || char == '\\' { builder.WriteByte('\\') } builder.WriteByte(char) } return builder.String() } func redisExactSearchPattern(literalKey string) (string, string) { return literalKey, escapeRedisGlobLiteral(literalKey) + ":*" } func (r *RedisClientImpl) toPhysicalKeys(keys []string) []string { if len(keys) == 0 { return nil } result := make([]string, 0, len(keys)) for _, key := range keys { physical := r.toPhysicalKey(key) if physical == "" { continue } result = append(result, physical) } return result } func (r *RedisClientImpl) toDisplayKey(key string) string { prefix := r.redisNamespacePrefix() if prefix == "" { return key } return strings.TrimPrefix(key, prefix) } // sanitizeRedisPassword 对 Redis 密码进行防御性 URL 解码。 // 当密码中包含 URL 编码序列(如 %40)时,尝试解码还原原始字符。 // 这可以防止前端 URI 构建中 encodeURIComponent 编码后的密码被误传入。 func sanitizeRedisPassword(password string) string { if password == "" { return password } // 仅当密码中包含 '%' 且后跟两位十六进制数字时,才尝试 URL 解码 if !strings.Contains(password, "%") { return password } decoded, err := url.QueryUnescape(password) if err != nil { // 解码失败,使用原始密码 return password } if decoded != password { logger.Warnf("Redis 密码检测到 URL 编码,已自动解码(原长度=%d 解码后长度=%d)", len(password), len(decoded)) } return decoded } // Connect establishes a connection to Redis func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error { config.Password = sanitizeRedisPassword(config.Password) r.config = config if r.config.RedisDB < 0 || r.config.RedisDB > 15 { r.config.RedisDB = 0 } r.currentDB = r.config.RedisDB r.forwarder = nil r.client = nil r.singleClient = nil r.clusterClient = nil r.isCluster = false seedAddrs, err := buildRedisSeedAddrs(config) if err != nil { return err } r.seedAddrs = append([]string(nil), seedAddrs...) topology := strings.ToLower(strings.TrimSpace(config.Topology)) r.isCluster = topology == "cluster" || len(seedAddrs) > 1 if r.isCluster && config.UseSSH { return fmt.Errorf("Redis 集群模式暂不支持 SSH 隧道,请关闭 SSH 后重试") } timeout := normalizeRedisTimeout(config.Timeout) if r.isCluster { attempts := []connection.ConnectionConfig{config} if shouldTryRedisSSLPreferredFallback(config) { attempts = append(attempts, withRedisSSLDisabled(config)) } var failures []string for idx, attempt := range attempts { var tlsConfig *tls.Config if cfg, err := resolveRedisTLSConfig(attempt); err != nil { failures = append(failures, fmt.Sprintf("第%d次 TLS 配置失败: %v", idx+1, err)) continue } else if cfg != nil { if host, _, err := net.SplitHostPort(seedAddrs[0]); err == nil && host != "" { cfg.ServerName = host } tlsConfig = cfg } opts := &redis.ClusterOptions{ Addrs: seedAddrs, Username: strings.TrimSpace(attempt.User), Password: attempt.Password, DialTimeout: timeout, ReadTimeout: timeout, WriteTimeout: timeout, TLSConfig: tlsConfig, } clusterClient := redis.NewClusterClient(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) pingErr := clusterClient.Ping(ctx).Err() cancel() if pingErr != nil { clusterClient.Close() failures = append(failures, fmt.Sprintf("第%d次连接失败: %v", idx+1, pingErr)) continue } r.client = clusterClient r.clusterClient = clusterClient r.config = attempt if idx > 0 { logger.Warnf("Redis 集群 SSL 优先连接失败,已回退至明文连接") } logger.Infof("Redis 集群连接成功: seeds=%s 逻辑库=db%d", strings.Join(seedAddrs, ","), r.currentDB) return nil } return fmt.Errorf("Redis 集群连接失败: %s", strings.Join(failures, ";")) } addr := seedAddrs[0] if config.UseSSH { forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port) if err != nil { return fmt.Errorf("创建 SSH 隧道失败: %w", err) } r.forwarder = forwarder addr = forwarder.LocalAddr logger.Infof("Redis 通过 SSH 隧道连接: %s -> %s:%d", addr, config.Host, config.Port) } attempts := []connection.ConnectionConfig{config} if shouldTryRedisSSLPreferredFallback(config) { attempts = append(attempts, withRedisSSLDisabled(config)) } var failures []string for idx, attempt := range attempts { var tlsConfig *tls.Config if cfg, err := resolveRedisTLSConfig(attempt); err != nil { failures = append(failures, fmt.Sprintf("第%d次 TLS 配置失败: %v", idx+1, err)) continue } else if cfg != nil { if host, _, err := net.SplitHostPort(addr); err == nil && host != "" { cfg.ServerName = host } tlsConfig = cfg } opts := &redis.Options{ Addr: addr, Username: strings.TrimSpace(attempt.User), Password: attempt.Password, DB: r.currentDB, DialTimeout: timeout, ReadTimeout: timeout, WriteTimeout: timeout, TLSConfig: tlsConfig, } singleClient := redis.NewClient(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) pingErr := singleClient.Ping(ctx).Err() cancel() if pingErr != nil { singleClient.Close() failures = append(failures, fmt.Sprintf("第%d次连接失败: %v", idx+1, pingErr)) continue } r.client = singleClient r.singleClient = singleClient r.config = attempt if idx > 0 { logger.Warnf("Redis SSL 优先连接失败,已回退至明文连接") } logger.Infof("Redis 连接成功: %s DB=%d", addr, r.currentDB) return nil } return fmt.Errorf("Redis 连接失败: %s", strings.Join(failures, ";")) } // Close closes the Redis connection func (r *RedisClientImpl) Close() error { if r.client != nil { err := r.client.Close() r.client = nil r.singleClient = nil r.clusterClient = nil r.isCluster = false r.seedAddrs = nil r.forwarder = nil return err } return nil } // Ping tests the connection func (r *RedisClientImpl) Ping() error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.Ping(ctx).Err() } // ScanKeys scans keys matching a pattern func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (*RedisScanResult, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } if pattern == "" { pattern = "*" } exactPhysicalKey := "" if literalKey, ok := redisGlobPatternLiteralKey(pattern); ok { exactKey, namespacePattern := redisExactSearchPattern(literalKey) exactPhysicalKey = r.toPhysicalKey(exactKey) if exactPhysicalKey == "" { return &RedisScanResult{Keys: []RedisKeyInfo{}, Cursor: "0"}, nil } pattern = namespacePattern } physicalPattern := r.toPhysicalPattern(pattern) isSearchPattern := pattern != "*" targetCount := normalizeRedisScanTargetCount(count) scanStepCount := normalizeRedisScanStepCount(targetCount) maxRounds := redisScanMaxRounds maxDuration := redisScanMaxDuration if isSearchPattern { if targetCount > redisSearchMaxTargetCount { targetCount = redisSearchMaxTargetCount } if scanStepCount > redisSearchMaxStepCount { scanStepCount = redisSearchMaxStepCount } maxRounds = redisSearchMaxRounds maxDuration = redisSearchMaxDuration } ctx, cancel := context.WithTimeout(context.Background(), maxDuration+5*time.Second) defer cancel() // 集群模式:逐 master 节点 SCAN 后合并去重 if r.isCluster && r.clusterClient != nil { keys := make([]string, 0, int(targetCount)) seen := make(map[string]struct{}, int(targetCount)) var mu sync.Mutex if exactPhysicalKey != "" { keys = append(keys, exactPhysicalKey) seen[exactPhysicalKey] = struct{}{} } err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error { var nodeCursor uint64 round := 0 scanStartedAt := time.Now() for { if time.Since(scanStartedAt) >= maxDuration { break } mu.Lock() enough := len(keys) >= int(targetCount) mu.Unlock() if enough { break } batch, nextCursor, err := node.Scan(nodeCtx, nodeCursor, physicalPattern, scanStepCount).Result() if err != nil { return err } mu.Lock() for _, key := range batch { if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} keys = append(keys, key) if len(keys) >= int(targetCount) { break } } mu.Unlock() nodeCursor = nextCursor round++ if nodeCursor == 0 || round >= maxRounds { break } } return nil }) if err != nil { return nil, err } // 集群模式 cursor 无意义,始终返回 "0" 表示扫描完成 return &RedisScanResult{ Keys: r.loadRedisKeyInfos(ctx, keys), Cursor: "0", }, nil } // 非集群模式:原逻辑 currentCursor := cursor round := 0 scanStartedAt := time.Now() keys := make([]string, 0, int(targetCount)) seen := make(map[string]struct{}, int(targetCount)) if exactPhysicalKey != "" && currentCursor == 0 { keys = append(keys, exactPhysicalKey) seen[exactPhysicalKey] = struct{}{} } for len(keys) < int(targetCount) { if time.Since(scanStartedAt) >= maxDuration { break } batch, nextCursor, err := r.client.Scan(ctx, currentCursor, physicalPattern, scanStepCount).Result() if err != nil { return nil, err } for _, key := range batch { if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} keys = append(keys, key) if len(keys) >= int(targetCount) { break } } currentCursor = nextCursor round++ if currentCursor == 0 || round >= maxRounds { break } } return &RedisScanResult{ Keys: r.loadRedisKeyInfos(ctx, keys), Cursor: strconv.FormatUint(currentCursor, 10), }, nil } func normalizeRedisScanTargetCount(count int64) int64 { if count <= 0 { return redisScanDefaultTargetCount } if count > redisScanMaxTargetCount { return redisScanMaxTargetCount } return count } func normalizeRedisScanStepCount(targetCount int64) int64 { if targetCount < redisScanMinStepCount { return redisScanMinStepCount } if targetCount > redisScanMaxStepCount { return redisScanMaxStepCount } return targetCount } func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo { result := make([]RedisKeyInfo, 0, len(keys)) if len(keys) == 0 { return result } pipe := r.client.Pipeline() typeResults := make([]*redis.StatusCmd, len(keys)) ttlResults := make([]*redis.DurationCmd, len(keys)) for i, key := range keys { typeResults[i] = pipe.Type(ctx, key) ttlResults[i] = pipe.TTL(ctx, key) } _, err := pipe.Exec(ctx) if err != nil && err != redis.Nil { for _, key := range keys { keyType, typeErr := r.client.Type(ctx, key).Result() if typeErr != nil && typeErr != redis.Nil { keyType = "" } ttlValue, ttlErr := r.client.TTL(ctx, key).Result() if ttlErr != nil && ttlErr != redis.Nil { ttlValue = -2 } ttlSeconds := toRedisTTLSeconds(ttlValue) if isRedisKeyGone(keyType, ttlSeconds) { continue } result = append(result, RedisKeyInfo{ Key: r.toDisplayKey(key), Type: keyType, TTL: ttlSeconds, }) } return result } for i, key := range keys { keyType := typeResults[i].Val() ttlSeconds := toRedisTTLSeconds(ttlResults[i].Val()) if isRedisKeyGone(keyType, ttlSeconds) { continue } result = append(result, RedisKeyInfo{ Key: r.toDisplayKey(key), Type: keyType, TTL: ttlSeconds, }) } return result } func toRedisTTLSeconds(ttl time.Duration) int64 { if ttl == -1 { return -1 } if ttl == -2 { return -2 } return int64(ttl.Seconds()) } func isRedisKeyGone(keyType string, ttl int64) bool { return keyType == "none" || ttl == -2 } func normalizeRedisGetValueError(keyType string, ttl int64) error { if isRedisKeyGone(keyType, ttl) { return ErrRedisKeyGone } return nil } // GetKeyType returns the type of a key func (r *RedisClientImpl) GetKeyType(key string) (string, error) { if r.client == nil { return "", fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.Type(ctx, r.toPhysicalKey(key)).Result() } // GetTTL returns the TTL of a key in seconds func (r *RedisClientImpl) GetTTL(key string) (int64, error) { if r.client == nil { return 0, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ttl, err := r.client.TTL(ctx, r.toPhysicalKey(key)).Result() if err != nil { return 0, err } if ttl == -1 { return -1, nil // No expiry } else if ttl == -2 { return -2, nil // Key doesn't exist } return int64(ttl.Seconds()), nil } // SetTTL sets the TTL of a key func (r *RedisClientImpl) SetTTL(key string, ttl int64) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if ttl < 0 { // Remove expiry return r.client.Persist(ctx, r.toPhysicalKey(key)).Err() } return r.client.Expire(ctx, r.toPhysicalKey(key), time.Duration(ttl)*time.Second).Err() } // DeleteKeys deletes one or more keys func (r *RedisClientImpl) DeleteKeys(keys []string) (int64, error) { if r.client == nil { return 0, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() physicalKeys := r.toPhysicalKeys(keys) if len(physicalKeys) == 0 { return 0, nil } return r.client.Del(ctx, physicalKeys...).Result() } // RenameKey renames a key func (r *RedisClientImpl) RenameKey(oldKey, newKey string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.Rename(ctx, r.toPhysicalKey(oldKey), r.toPhysicalKey(newKey)).Err() } // KeyExists checks if a key exists func (r *RedisClientImpl) KeyExists(key string) (bool, error) { if r.client == nil { return false, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() n, err := r.client.Exists(ctx, r.toPhysicalKey(key)).Result() return n > 0, err } // GetValue gets the value of a key with automatic type detection func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } keyType, err := r.GetKeyType(key) if err != nil { return nil, err } ttl, _ := r.GetTTL(key) if err := normalizeRedisGetValueError(keyType, ttl); err != nil { return nil, err } physicalKey := r.toPhysicalKey(key) result := &RedisValue{ Type: keyType, TTL: ttl, } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() switch keyType { case "string": val, err := r.client.Get(ctx, physicalKey).Result() if err != nil { return nil, err } result.Value = val result.Length = int64(len(val)) case "hash": val, length, err := r.readHashEntries(ctx, physicalKey) if err != nil { return nil, err } result.Value = val result.Length = length case "list": length, err := r.client.LLen(ctx, physicalKey).Result() if err != nil { return nil, err } // Get first 1000 items limit := int64(1000) if length < limit { limit = length } val, err := r.client.LRange(ctx, physicalKey, 0, limit-1).Result() if err != nil { return nil, err } result.Value = val result.Length = length case "set": length, err := r.client.SCard(ctx, physicalKey).Result() if err != nil { return nil, err } // Get members using SMembers (limited by Redis server) members, err := r.client.SMembers(ctx, physicalKey).Result() if err != nil { return nil, err } result.Value = members result.Length = length case "zset": length, err := r.client.ZCard(ctx, physicalKey).Result() if err != nil { return nil, err } // Get first 1000 members with scores limit := int64(1000) if length < limit { limit = length } val, err := r.client.ZRangeWithScores(ctx, physicalKey, 0, limit-1).Result() if err != nil { return nil, err } members := make([]ZSetMember, len(val)) for i, z := range val { members[i] = ZSetMember{ Member: z.Member.(string), Score: z.Score, } } result.Value = members result.Length = length case "stream": length, err := r.client.XLen(ctx, physicalKey).Result() if err != nil { return nil, err } result.Length = length if length == 0 { result.Value = []StreamEntry{} break } limit := int64(1000) if length < limit { limit = length } val, err := r.client.XRangeN(ctx, physicalKey, "-", "+", limit).Result() if err != nil { return nil, err } result.Value = toStreamEntries(val) default: return nil, fmt.Errorf("不支持的 Redis 数据类型: %s", keyType) } return result, nil } // GetString gets a string value func (r *RedisClientImpl) GetString(key string) (string, error) { if r.client == nil { return "", fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.Get(ctx, r.toPhysicalKey(key)).Result() } // SetString sets a string value with optional TTL func (r *RedisClientImpl) SetString(key, value string, ttl int64) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var expiration time.Duration if ttl > 0 { expiration = time.Duration(ttl) * time.Second } return r.client.Set(ctx, r.toPhysicalKey(key), value, expiration).Err() } // GetHash gets all fields of a hash func (r *RedisClientImpl) GetHash(key string) (map[string]string, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() values, _, err := r.readHashEntries(ctx, r.toPhysicalKey(key)) return values, err } func (r *RedisClientImpl) readHashEntries(ctx context.Context, physicalKey string) (map[string]string, int64, error) { return readRedisHashEntriesWithFallback( func() (map[string]string, error) { return r.client.HGetAll(ctx, physicalKey).Result() }, func() (int64, error) { return r.client.HLen(ctx, physicalKey).Result() }, func(cursor uint64, count int64) ([]string, uint64, error) { return r.client.HScan(ctx, physicalKey, cursor, "*", count).Result() }, ) } func readRedisHashEntriesWithFallback( readAll func() (map[string]string, error), readLength func() (int64, error), scan func(cursor uint64, count int64) ([]string, uint64, error), ) (map[string]string, int64, error) { values, err := readAll() if err == nil { return values, int64(len(values)), nil } if !shouldFallbackRedisHashScan(err) { return nil, 0, err } entries := make(map[string]string) var cursor uint64 for round := 0; round < redisScanMaxRounds; round++ { pairs, nextCursor, scanErr := scan(cursor, redisScanMinStepCount) if scanErr != nil { return nil, 0, scanErr } if len(pairs)%2 != 0 { return nil, 0, fmt.Errorf("Redis HSCAN 返回结果格式异常") } for i := 0; i < len(pairs); i += 2 { entries[pairs[i]] = pairs[i+1] } cursor = nextCursor if cursor == 0 { length, lengthErr := readLength() if lengthErr == nil { return entries, length, nil } return entries, int64(len(entries)), nil } } return nil, 0, fmt.Errorf("Redis HSCAN 超出安全轮次,无法完整读取 hash") } func shouldFallbackRedisHashScan(err error) bool { if err == nil { return false } message := strings.ToLower(strings.TrimSpace(err.Error())) if !strings.Contains(message, "hgetall") { return false } return strings.Contains(message, "not support for normal user") || strings.Contains(message, "noperm") || strings.Contains(message, "permission") } // SetHashField sets a field in a hash func (r *RedisClientImpl) SetHashField(key, field, value string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.HSet(ctx, r.toPhysicalKey(key), field, value).Err() } // DeleteHashField deletes fields from a hash func (r *RedisClientImpl) DeleteHashField(key string, fields ...string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.HDel(ctx, r.toPhysicalKey(key), fields...).Err() } // GetList gets a range of elements from a list func (r *RedisClientImpl) GetList(key string, start, stop int64) ([]string, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return r.client.LRange(ctx, r.toPhysicalKey(key), start, stop).Result() } // ListPush pushes values to the end of a list func (r *RedisClientImpl) ListPush(key string, values ...string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() args := make([]interface{}, len(values)) for i, v := range values { args[i] = v } return r.client.RPush(ctx, r.toPhysicalKey(key), args...).Err() } // ListSet sets the value at an index in a list func (r *RedisClientImpl) ListSet(key string, index int64, value string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.LSet(ctx, r.toPhysicalKey(key), index, value).Err() } // GetSet gets all members of a set func (r *RedisClientImpl) GetSet(key string) ([]string, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return r.client.SMembers(ctx, r.toPhysicalKey(key)).Result() } // SetAdd adds members to a set func (r *RedisClientImpl) SetAdd(key string, members ...string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() args := make([]interface{}, len(members)) for i, m := range members { args[i] = m } return r.client.SAdd(ctx, r.toPhysicalKey(key), args...).Err() } // SetRemove removes members from a set func (r *RedisClientImpl) SetRemove(key string, members ...string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() args := make([]interface{}, len(members)) for i, m := range members { args[i] = m } return r.client.SRem(ctx, r.toPhysicalKey(key), args...).Err() } // GetZSet gets members with scores from a sorted set func (r *RedisClientImpl) GetZSet(key string, start, stop int64) ([]ZSetMember, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() val, err := r.client.ZRangeWithScores(ctx, r.toPhysicalKey(key), start, stop).Result() if err != nil { return nil, err } members := make([]ZSetMember, len(val)) for i, z := range val { members[i] = ZSetMember{ Member: z.Member.(string), Score: z.Score, } } return members, nil } // ZSetAdd adds members to a sorted set func (r *RedisClientImpl) ZSetAdd(key string, members ...ZSetMember) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() zMembers := make([]redis.Z, len(members)) for i, m := range members { zMembers[i] = redis.Z{ Score: m.Score, Member: m.Member, } } return r.client.ZAdd(ctx, r.toPhysicalKey(key), zMembers...).Err() } // ZSetRemove removes members from a sorted set func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() args := make([]interface{}, len(members)) for i, m := range members { args[i] = m } return r.client.ZRem(ctx, r.toPhysicalKey(key), args...).Err() } // GetStream gets stream entries in a range func (r *RedisClientImpl) GetStream(key, start, stop string, count int64) ([]StreamEntry, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } if start == "" { start = "-" } if stop == "" { stop = "+" } if count <= 0 { count = 1000 } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() val, err := r.client.XRangeN(ctx, r.toPhysicalKey(key), start, stop, count).Result() if err != nil { return nil, err } return toStreamEntries(val), nil } // StreamAdd adds an entry to a stream func (r *RedisClientImpl) StreamAdd(key string, fields map[string]string, id string) (string, error) { if r.client == nil { return "", fmt.Errorf("Redis 客户端未连接") } if len(fields) == 0 { return "", fmt.Errorf("Stream 字段不能为空") } if id == "" { id = "*" } values := make(map[string]interface{}, len(fields)) for field, value := range fields { values[field] = value } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() newID, err := r.client.XAdd(ctx, &redis.XAddArgs{ Stream: r.toPhysicalKey(key), ID: id, Values: values, }).Result() if err != nil { return "", err } return newID, nil } // StreamDelete deletes entries from a stream by IDs func (r *RedisClientImpl) StreamDelete(key string, ids ...string) (int64, error) { if r.client == nil { return 0, fmt.Errorf("Redis 客户端未连接") } if len(ids) == 0 { return 0, fmt.Errorf("Stream ID 不能为空") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return r.client.XDel(ctx, r.toPhysicalKey(key), ids...).Result() } func toStreamEntries(messages []redis.XMessage) []StreamEntry { entries := make([]StreamEntry, 0, len(messages)) for _, msg := range messages { fields := make(map[string]string, len(msg.Values)) for field, value := range msg.Values { fields[field] = fmt.Sprint(value) } entries = append(entries, StreamEntry{ ID: msg.ID, Fields: fields, }) } return entries } func parseRedisCommandGetKeysResult(result interface{}) []string { items, ok := result.([]interface{}) if !ok || len(items) == 0 { return nil } keys := make([]string, 0, len(items)) for _, item := range items { switch v := item.(type) { case string: if v != "" { keys = append(keys, v) } case []byte: text := string(v) if text != "" { keys = append(keys, text) } } } return keys } func (r *RedisClientImpl) rewriteCommandArgsForNamespace(ctx context.Context, args []string) []string { if !r.isCluster || r.currentDB <= 0 || len(args) == 0 { return args } command := strings.ToUpper(strings.TrimSpace(args[0])) if command == "COMMAND" || command == "SELECT" || command == "FLUSHDB" { return args } probeArgs := make([]interface{}, 0, len(args)+2) probeArgs = append(probeArgs, "COMMAND", "GETKEYS") for _, arg := range args { probeArgs = append(probeArgs, arg) } result, err := r.client.Do(ctx, probeArgs...).Result() if err != nil { return args } keyCandidates := parseRedisCommandGetKeysResult(result) if len(keyCandidates) == 0 { return args } rewritten := append([]string(nil), args...) used := make([]bool, len(rewritten)) for _, key := range keyCandidates { for i := 1; i < len(rewritten); i++ { if used[i] { continue } if rewritten[i] != key { continue } rewritten[i] = r.toPhysicalKey(rewritten[i]) used[i] = true break } } return rewritten } // ExecuteCommand executes a raw Redis command func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } if len(args) == 0 { return nil, fmt.Errorf("命令不能为空") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if r.isCluster { command := strings.ToUpper(strings.TrimSpace(args[0])) switch command { case "SELECT": if len(args) < 2 { return nil, fmt.Errorf("SELECT 命令缺少数据库索引") } index, err := strconv.Atoi(strings.TrimSpace(args[1])) if err != nil { return nil, fmt.Errorf("无效数据库索引: %s", args[1]) } if index < 0 || index > 15 { return nil, fmt.Errorf("数据库索引必须在 0-15 之间") } r.currentDB = index r.config.RedisDB = index return "OK", nil case "FLUSHDB": if err := r.FlushDB(); err != nil { return nil, err } return "OK", nil } } args = r.rewriteCommandArgsForNamespace(ctx, args) // Convert to []interface{} cmdArgs := make([]interface{}, len(args)) for i, arg := range args { cmdArgs[i] = arg } result, err := r.client.Do(ctx, cmdArgs...).Result() if err != nil { return nil, err } return formatCommandResult(result), nil } // formatCommandResult formats the command result for display. // // RESP3 协议(go-redis v9 默认)下,HGETALL / CONFIG GET / XINFO 等命令返回 Map 类型, // go-redis 用 map[interface{}]interface{} 承载。encoding/json 不支持非 string-key 的 map, // 如果让原值穿透到 Wails RPC,json.Marshal 会失败,Wails runtime 在 Windows 上会直接 panic // 让进程退出——用户感知为 GoNavi 闪退(issue: HGETALL 闪退)。 // 平展成 [k1, v1, k2, v2, ...] 交错形式与 RESP2 array 输出一致,前端按 array 渲染。 // // 这里同时把 RESP3 的 NaN/Inf 浮点、大整数、error 以及其他 map/slice 形态统一收敛为 // JSON-safe 结构,避免 Redis 命令面板再把不可序列化的值透传给 Wails。 func formatCommandResult(result interface{}) interface{} { switch v := result.(type) { case []interface{}: formatted := make([]interface{}, len(v)) for i, item := range v { formatted[i] = formatCommandResult(item) } return formatted case map[interface{}]interface{}: flattened := make([]interface{}, 0, len(v)*2) for key, val := range v { flattened = append(flattened, formatCommandResult(key)) flattened = append(flattened, formatCommandResult(val)) } return flattened case map[string]interface{}: formatted := make(map[string]interface{}, len(v)) for key, val := range v { formatted[key] = formatCommandResult(val) } return formatted case []byte: return string(v) case error: return v.Error() case *big.Int: if v == nil { return nil } return v.String() case float64: if math.IsNaN(v) || math.IsInf(v, 0) { return fmt.Sprint(v) } return v case float32: f := float64(v) if math.IsNaN(f) || math.IsInf(f, 0) { return fmt.Sprint(v) } return v default: return formatCommandResultByReflection(v) } } func formatCommandResultByReflection(result interface{}) interface{} { value := reflect.ValueOf(result) if !value.IsValid() { return nil } switch value.Kind() { case reflect.Map: if value.Type().Key().Kind() == reflect.String { formatted := make(map[string]interface{}, value.Len()) iter := value.MapRange() for iter.Next() { formatted[iter.Key().String()] = formatCommandResult(iter.Value().Interface()) } return formatted } flattened := make([]interface{}, 0, value.Len()*2) iter := value.MapRange() for iter.Next() { flattened = append(flattened, formatCommandResult(iter.Key().Interface())) flattened = append(flattened, formatCommandResult(iter.Value().Interface())) } return flattened case reflect.Slice, reflect.Array: formatted := make([]interface{}, value.Len()) for i := 0; i < value.Len(); i++ { formatted[i] = formatCommandResult(value.Index(i).Interface()) } return formatted default: return result } } // GetServerInfo returns server information func (r *RedisClientImpl) GetServerInfo() (map[string]string, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() info, err := r.client.Info(ctx).Result() if err != nil { return nil, err } result := make(map[string]string) lines := strings.Split(info, "\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[parts[0]] = parts[1] } } return result, nil } // GetDatabases returns information about all databases func (r *RedisClientImpl) GetDatabases() ([]RedisDBInfo, error) { if r.client == nil { return nil, fmt.Errorf("Redis 客户端未连接") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if r.isCluster && r.clusterClient != nil { var totalKeys int64 var mu sync.Mutex err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error { keys, err := node.DBSize(nodeCtx).Result() if err != nil { return err } mu.Lock() totalKeys += keys mu.Unlock() return nil }) if err != nil { logger.Warnf("Redis 集群获取 key 数量失败,回退为 0: %v", err) totalKeys = 0 } result := make([]RedisDBInfo, 16) for i := 0; i < 16; i++ { result[i] = RedisDBInfo{Index: i, Keys: 0} } result[0].Keys = totalKeys return result, nil } // Get keyspace info info, err := r.client.Info(ctx, "keyspace").Result() if err != nil { 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++ { result[i] = RedisDBInfo{ Index: i, Keys: dbMap[i], // Will be 0 if not in map } } return result, nil } // SelectDB selects a database func (r *RedisClientImpl) SelectDB(index int) error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } if r.isCluster { if index < 0 || index > 15 { return fmt.Errorf("数据库索引必须在 0-15 之间") } r.currentDB = index r.config.RedisDB = index return nil } if index < 0 || index > 15 { return fmt.Errorf("数据库索引必须在 0-15 之间") } // Create new client with different DB addr := "" if len(r.seedAddrs) > 0 { addr = r.seedAddrs[0] } if r.forwarder != nil { addr = r.forwarder.LocalAddr } if addr == "" { addr = fmt.Sprintf("%s:%d", r.config.Host, r.config.Port) } timeout := normalizeRedisTimeout(r.config.Timeout) opts := &redis.Options{ Addr: addr, Username: strings.TrimSpace(r.config.User), Password: r.config.Password, DB: index, DialTimeout: timeout, ReadTimeout: timeout, WriteTimeout: timeout, } newClient := redis.NewClient(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := newClient.Ping(ctx).Err(); err != nil { newClient.Close() return fmt.Errorf("切换数据库失败: %w", err) } // Close old client and replace if r.client != nil { _ = r.client.Close() } r.client = newClient r.singleClient = newClient r.clusterClient = nil r.currentDB = index r.config.RedisDB = index logger.Infof("Redis 切换到数据库: db%d", index) return nil } // GetCurrentDB returns the current database index func (r *RedisClientImpl) GetCurrentDB() int { return r.currentDB } // FlushDB flushes the current database func (r *RedisClientImpl) FlushDB() error { if r.client == nil { return fmt.Errorf("Redis 客户端未连接") } if r.isCluster && r.clusterClient != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() namespacePrefix := r.redisNamespacePrefix() var deletedTotal int64 var deletedMu sync.Mutex err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error { var cursor uint64 for { pattern := "*" if namespacePrefix != "" { pattern = namespacePrefix + "*" } keys, nextCursor, err := node.Scan(nodeCtx, cursor, pattern, 2000).Result() if err != nil { return err } if namespacePrefix == "" { filtered := keys[:0] for _, key := range keys { // db0 保留兼容:不删除逻辑库前缀 key,避免误清理 db1~db15。 if strings.HasPrefix(key, "__gonavi_db_") { continue } filtered = append(filtered, key) } keys = filtered } if len(keys) > 0 { deleted, err := node.Del(nodeCtx, keys...).Result() if err != nil { return err } deletedMu.Lock() deletedTotal += deleted deletedMu.Unlock() } cursor = nextCursor if cursor == 0 { break } } return nil }) if err != nil { return err } logger.Infof("Redis 集群逻辑库清空完成: db%d deleted=%d", r.currentDB, deletedTotal) return nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return r.client.FlushDB(ctx).Err() }