From 4de3f408c51b50e4da83d33a230f3c03276d54cb Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 12:32:22 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(redis-scan):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=A7=E6=95=B0=E6=8D=AE=E9=87=8F=E4=B8=8B=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=A9=BA=E9=97=B4=E5=8A=A0=E8=BD=BD=E4=B8=8D=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前后端 Redis SCAN 游标统一为字符串传递,避免 Number 精度丢失 - RedisScanKeys 增加 string/number 游标兼容解析,异常游标降级并告警 - 新增游标解析单测 - refs #135 --- frontend/src/components/RedisViewer.tsx | 35 ++++++--- frontend/src/types.ts | 2 +- frontend/wailsjs/go/app/App.d.ts | 2 +- internal/app/methods_redis.go | 89 ++++++++++++++++++++++- internal/app/methods_redis_cursor_test.go | 50 +++++++++++++ internal/redis/redis.go | 2 +- internal/redis/redis_impl.go | 2 +- 7 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 internal/app/methods_redis_cursor_test.go diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 0aa3750..c62aced 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -281,6 +281,23 @@ const getRedisScanLoadCount = (pattern: string, append: boolean): number => { return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT; }; +const normalizeRedisCursor = (value: unknown): string => { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed === '' ? '0' : trimmed; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return '0'; + } + return Math.trunc(value).toString(); + } + if (typeof value === 'bigint') { + return value.toString(); + } + return '0'; +}; + const normalizeKeySegment = (segment: string): string => { return segment === '' ? EMPTY_SEGMENT_LABEL : segment; }; @@ -384,7 +401,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); const [searchPattern, setSearchPattern] = useState('*'); - const [cursor, setCursor] = useState(0); + const [cursor, setCursor] = useState('0'); const [hasMore, setHasMore] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [keyValue, setKeyValue] = useState(null); @@ -433,7 +450,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const loadKeys = useCallback(async ( pattern: string = '*', - fromCursor: number = 0, + fromCursor: string = '0', append: boolean = false, targetCount?: number ) => { @@ -454,7 +471,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { if (res.success) { const result = res.data; const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; - const nextCursor = Number(result?.cursor || 0); + const nextCursor = normalizeRedisCursor(result?.cursor); if (append) { setKeys(prev => { const keyMap = new Map(); @@ -466,7 +483,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setKeys(scannedKeys); } setCursor(nextCursor); - setHasMore(nextCursor !== 0); + setHasMore(nextCursor !== '0'); } else { message.error('加载 Key 失败: ' + res.message); } @@ -483,14 +500,14 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }, [getConfig]); useEffect(() => { - loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false)); + loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); }, [redisDB]); const handleSearch = (value: string) => { const pattern = value.trim() || '*'; setSearchPattern(pattern); - setCursor(0); - loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false)); + setCursor('0'); + loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false)); }; const handleLoadMore = () => { @@ -501,8 +518,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const handleRefresh = () => { - setCursor(0); - loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false)); + setCursor('0'); + loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); }; const loadKeyValue = async (key: string) => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f700677..e8a6cb4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -137,7 +137,7 @@ export interface RedisKeyInfo { export interface RedisScanResult { keys: RedisKeyInfo[]; - cursor: number; + cursor: string; } export interface RedisValue { diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index ce594ff..8f3c0e9 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -124,7 +124,7 @@ export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:n export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; -export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise; +export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string|number,arg4:number):Promise; export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise; diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index f356277..e88d79d 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -4,6 +4,9 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" + "math" + "strconv" "strings" "sync" @@ -107,14 +110,20 @@ func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection } // RedisScanKeys scans keys matching a pattern -func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor uint64, count int64) connection.QueryResult { +func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor any, count int64) connection.QueryResult { config.Type = "redis" client, err := a.getRedisClient(config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - result, err := client.ScanKeys(pattern, cursor, count) + parsedCursor, err := parseRedisScanCursor(cursor) + if err != nil { + logger.Warnf("RedisScanKeys 游标解析失败,已回退到起始游标:cursor=%v err=%v", cursor, err) + parsedCursor = 0 + } + + result, err := client.ScanKeys(pattern, parsedCursor, count) if err != nil { logger.Error(err, "RedisScanKeys 扫描失败:pattern=%s", pattern) return connection.QueryResult{Success: false, Message: err.Error()} @@ -123,6 +132,82 @@ func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, return connection.QueryResult{Success: true, Data: result} } +func parseRedisScanCursor(cursor any) (uint64, error) { + switch v := cursor.(type) { + case nil: + return 0, nil + case uint64: + return v, nil + case uint32: + return uint64(v), nil + case uint16: + return uint64(v), nil + case uint8: + return uint64(v), nil + case uint: + return uint64(v), nil + case int64: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int32: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int16: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int8: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case float64: + return parseRedisScanCursorFromFloat(v) + case float32: + return parseRedisScanCursorFromFloat(float64(v)) + case json.Number: + return parseRedisScanCursor(strings.TrimSpace(v.String())) + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return 0, nil + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return 0, fmt.Errorf("无效游标: %q", v) + } + return parsed, nil + default: + return 0, fmt.Errorf("不支持的游标类型: %T", cursor) + } +} + +func parseRedisScanCursorFromFloat(value float64) (uint64, error) { + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, fmt.Errorf("无效浮点游标: %v", value) + } + if value < 0 { + return 0, fmt.Errorf("游标不能为负数: %v", value) + } + if math.Trunc(value) != value { + return 0, fmt.Errorf("游标必须为整数: %v", value) + } + if value > float64(math.MaxUint64) { + return 0, fmt.Errorf("游标超出范围: %v", value) + } + return uint64(value), nil +} + // RedisGetValue gets the value of a key func (a *App) RedisGetValue(config connection.ConnectionConfig, key string) connection.QueryResult { config.Type = "redis" diff --git a/internal/app/methods_redis_cursor_test.go b/internal/app/methods_redis_cursor_test.go new file mode 100644 index 0000000..e121d8f --- /dev/null +++ b/internal/app/methods_redis_cursor_test.go @@ -0,0 +1,50 @@ +package app + +import ( + "encoding/json" + "testing" +) + +func TestParseRedisScanCursor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input any + want uint64 + wantErr bool + }{ + {name: "nil defaults to zero", input: nil, want: 0}, + {name: "empty string defaults to zero", input: " ", want: 0}, + {name: "string cursor", input: "123", want: 123}, + {name: "uint64 cursor", input: uint64(456), want: 456}, + {name: "int cursor", input: int(789), want: 789}, + {name: "float cursor", input: float64(42), want: 42}, + {name: "json number cursor", input: json.Number("88"), want: 88}, + {name: "negative int rejected", input: -1, wantErr: true}, + {name: "fraction float rejected", input: float64(1.5), wantErr: true}, + {name: "invalid string rejected", input: "abc", wantErr: true}, + {name: "unsupported type rejected", input: true, wantErr: true}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseRedisScanCursor(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (value=%d)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("parseRedisScanCursor() mismatch, want=%d got=%d", tc.want, got) + } + }) + } +} diff --git a/internal/redis/redis.go b/internal/redis/redis.go index 7e0416d..80e58f6 100644 --- a/internal/redis/redis.go +++ b/internal/redis/redis.go @@ -26,7 +26,7 @@ type RedisKeyInfo struct { // RedisScanResult represents the result of a SCAN operation type RedisScanResult struct { Keys []RedisKeyInfo `json:"keys"` - Cursor uint64 `json:"cursor"` + Cursor string `json:"cursor"` } // RedisClient defines the interface for Redis operations diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 50df382..044f16d 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -175,7 +175,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( return &RedisScanResult{ Keys: r.loadRedisKeyInfos(ctx, keys), - Cursor: currentCursor, + Cursor: strconv.FormatUint(currentCursor, 10), }, nil }