mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
🐛 fix(redis-scan): 修复大数据量下命名空间加载不完整问题
- 前后端 Redis SCAN 游标统一为字符串传递,避免 Number 精度丢失 - RedisScanKeys 增加 string/number 游标兼容解析,异常游标降级并告警 - 新增游标解析单测 - refs #135
This commit is contained in:
@@ -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<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchPattern, setSearchPattern] = useState('*');
|
||||
const [cursor, setCursor] = useState<number>(0);
|
||||
const [cursor, setCursor] = useState<string>('0');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
|
||||
@@ -433,7 +450,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<string, RedisKeyInfo>();
|
||||
@@ -466,7 +483,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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) => {
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface RedisKeyInfo {
|
||||
|
||||
export interface RedisScanResult {
|
||||
keys: RedisKeyInfo[];
|
||||
cursor: number;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface RedisValue {
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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<connection.QueryResult>;
|
||||
|
||||
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise<connection.QueryResult>;
|
||||
export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string|number,arg4:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise<connection.QueryResult>;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
50
internal/app/methods_redis_cursor_test.go
Normal file
50
internal/app/methods_redis_cursor_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user