From 1b31c54917b2b2b002ef1ebdfd4c6ba393294a58 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 27 Apr 2026 11:31:20 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(redis):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E6=90=9C=E7=B4=A2=E6=97=A0=E6=B3=95=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 精确搜索识别无通配符的 Redis literal pattern - 同时查询完整 Key 与同名命名空间前缀 - 修复输入 Agent 无法显示 Agent 文件夹的问题 - 避免误匹配 AgentCapacity、AgentState 等相似前缀 - 补充 glob literal 与命名空间搜索回归测试 - 更新 Redis 精确搜索输入提示文案 --- frontend/src/components/RedisViewer.tsx | 2 +- internal/redis/redis_impl.go | 57 ++++++++++++++++++++++ internal/redis/redis_impl_test.go | 63 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 2edeaf4..c9cfe7e 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1852,7 +1852,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { = 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 @@ -367,6 +407,15 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( 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 != "*" @@ -393,6 +442,10 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( 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 @@ -453,6 +506,10 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( 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 { diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index 914e088..9f08b79 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -120,6 +120,69 @@ func TestNormalizeRedisGetValueError(t *testing.T) { } } +func TestRedisGlobPatternLiteralKey(t *testing.T) { + tests := []struct { + name string + pattern string + wantKey string + wantExact bool + }{ + {name: "plain exact key", pattern: "Agent", wantKey: "Agent", wantExact: true}, + {name: "escaped glob characters stay literal", pattern: `user:\*:\[id\]\?\\raw`, wantKey: `user:*:[id]?\raw`, wantExact: true}, + {name: "fuzzy wildcard is not exact", pattern: "*[aA][gG][eE][nN][tT]*", wantExact: false}, + {name: "unescaped suffix wildcard is not exact", pattern: "Agent*", wantExact: false}, + {name: "unescaped single character wildcard is not exact", pattern: "Agent?", wantExact: false}, + {name: "unescaped character class is not exact", pattern: "Agent[0-9]", wantExact: false}, + {name: "empty pattern is not exact", pattern: "", wantExact: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotKey, gotExact := redisGlobPatternLiteralKey(tt.pattern) + if gotExact != tt.wantExact { + t.Fatalf("redisGlobPatternLiteralKey(%q) exact=%v, want %v", tt.pattern, gotExact, tt.wantExact) + } + if gotKey != tt.wantKey { + t.Fatalf("redisGlobPatternLiteralKey(%q) key=%q, want %q", tt.pattern, gotKey, tt.wantKey) + } + }) + } +} + +func TestRedisExactSearchPattern(t *testing.T) { + tests := []struct { + name string + literalKey string + wantExactKey string + wantNamespace string + }{ + { + name: "plain namespace folder", + literalKey: "Agent", + wantExactKey: "Agent", + wantNamespace: "Agent:*", + }, + { + name: "escaped namespace keeps glob chars literal", + literalKey: `user:*:[id]?\raw`, + wantExactKey: `user:*:[id]?\raw`, + wantNamespace: `user:\*:\[id\]\?\\raw:*`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotExactKey, gotNamespace := redisExactSearchPattern(tt.literalKey) + if gotExactKey != tt.wantExactKey { + t.Fatalf("redisExactSearchPattern(%q) exactKey=%q, want %q", tt.literalKey, gotExactKey, tt.wantExactKey) + } + if gotNamespace != tt.wantNamespace { + t.Fatalf("redisExactSearchPattern(%q) namespace=%q, want %q", tt.literalKey, gotNamespace, tt.wantNamespace) + } + }) + } +} + func TestReadRedisHashEntriesWithFallbackUsesHScanWhenHGetAllForbidden(t *testing.T) { scanCalls := 0 values, length, err := readRedisHashEntriesWithFallback(