From 22e4299d3e6d10558706801f22fb8a1d522fa3a8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 17 Apr 2026 12:45:21 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(redis):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20hash=20=E5=AD=97=E6=AE=B5=E5=88=A0=E9=99=A4=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=BA=8F=E5=88=97=E5=8C=96=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端统一按数组传递 hash 字段删除参数 - 后端兼容单字符串与数组两种删除入参 - 补充 Redis hash 字段删除回归测试 Fixes #343 --- .../2026-04-11-issue-backlog-tracking.md | 7 ++ frontend/src/components/RedisViewer.tsx | 2 +- internal/app/methods_redis.go | 56 ++++++++++- internal/app/methods_redis_test.go | 96 +++++++++++++++++-- 4 files changed, 149 insertions(+), 12 deletions(-) diff --git a/docs/issues/2026-04-11-issue-backlog-tracking.md b/docs/issues/2026-04-11-issue-backlog-tracking.md index 3d9a258..d819a08 100644 --- a/docs/issues/2026-04-11-issue-backlog-tracking.md +++ b/docs/issues/2026-04-11-issue-backlog-tracking.md @@ -34,6 +34,7 @@ | #337 | 自动更新无效 | Fixed | Pending | | #338 | 连接clickhouse不能通过8132端口 | Fixed | Pending | | #342 | 数据同步功能不能用,mysql数据库8.4版本选了结构同步,最后没同步成功 | Fixed | Pending | +| #343 | redis删除hash类型中的key报错 | Fixed | Pending | | #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending | ## Notes @@ -116,6 +117,12 @@ - 处理:将 existing-target 分支的自动补字段逻辑改为复用通用 `buildAddColumnSQLForPair`,让 `MySQL -> MySQL` 也能生成并执行缺失字段补齐 SQL;同时为 analyze/preview 响应补充 `schemaDiffCount`、`schemaStatements`、`schemaSummary` 和 warning 信息,前端 schema 模式下可直接查看结构变更语句与风险提示,SQL 预览也会包含结构语句。 - 验证:新增 `internal/sync/schema_migration_test.go` 回归测试,覆盖 `MySQL -> MySQL` 已存在目标表时生成补字段 SQL,并执行 `go test ./internal/sync -count=1` 与 `frontend` 下 `npm run build`。 +### #343 + +- 根因:前端 Redis hash 字段删除调用把单个字段 `string` 直接传给 `RedisDeleteHashField`,而后端/Wails 绑定签名要求的是 `[]string`,导致在参数反序列化阶段直接报 `json: cannot unmarshal string into Go value of type []string`。 +- 处理:前端改为传单元素数组;后端再增加一层参数归一化,兼容单字符串、字符串数组和 `[]interface{}` 三种形态,避免旧调用或异常入参再次在绑定层直接失败。 +- 验证:新增 `internal/app/methods_redis_test.go` 回归测试,覆盖单字符串与字符串数组两种调用形态,并执行 `go test ./internal/app -count=1` 与 `frontend` 下 `npm run build`。 + ### #330 - 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。 diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 6a1a4ff..61a39e5 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1194,7 +1194,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const config = getConfig(); if (!config) return; try { - const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field); + const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]); if (res.success) { message.success('删除成功'); loadKeyValue(selectedKey); diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 71b70a7..8ac2136 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -18,8 +18,8 @@ import ( // Redis client cache var ( - redisCache = make(map[string]redis.RedisClient) - redisCacheMu sync.Mutex + redisCache = make(map[string]redis.RedisClient) + redisCacheMu sync.Mutex newRedisClientFunc = redis.NewRedisClient ) @@ -539,16 +539,62 @@ func (a *App) RedisKeyExists(config connection.ConnectionConfig, key string) con return connection.QueryResult{Success: true, Data: map[string]bool{"exists": exists}} } +func normalizeRedisStringArgs(raw any, argName string) ([]string, error) { + switch v := raw.(type) { + case nil: + return nil, fmt.Errorf("%s 不能为空", argName) + case string: + text := strings.TrimSpace(v) + if text == "" { + return nil, fmt.Errorf("%s 不能为空", argName) + } + return []string{text}, nil + case []string: + items := make([]string, 0, len(v)) + for _, item := range v { + text := strings.TrimSpace(item) + if text == "" { + continue + } + items = append(items, text) + } + if len(items) == 0 { + return nil, fmt.Errorf("%s 不能为空", argName) + } + return items, nil + case []interface{}: + items := make([]string, 0, len(v)) + for _, item := range v { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text == "" || text == "" { + continue + } + items = append(items, text) + } + if len(items) == 0 { + return nil, fmt.Errorf("%s 不能为空", argName) + } + return items, nil + default: + return nil, fmt.Errorf("%s 类型无效", argName) + } +} + // RedisDeleteHashField deletes fields from a hash -func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult { +func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields any) connection.QueryResult { config.Type = "redis" client, err := a.getRedisClient(config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - if err := client.DeleteHashField(key, fields...); err != nil { - logger.Error(err, "RedisDeleteHashField 删除失败:key=%s fields=%v", key, fields) + normalizedFields, err := normalizeRedisStringArgs(fields, "fields") + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if err := client.DeleteHashField(key, normalizedFields...); err != nil { + logger.Error(err, "RedisDeleteHashField 删除失败:key=%s fields=%v", key, normalizedFields) return connection.QueryResult{Success: false, Message: err.Error()} } diff --git a/internal/app/methods_redis_test.go b/internal/app/methods_redis_test.go index 5f801bf..d17c4f0 100644 --- a/internal/app/methods_redis_test.go +++ b/internal/app/methods_redis_test.go @@ -9,7 +9,9 @@ import ( ) type capturingRedisClient struct { - connectConfig connection.ConnectionConfig + connectConfig connection.ConnectionConfig + deletedHashKey string + deletedHashFields []string } func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error { @@ -45,13 +47,21 @@ func (c *capturingRedisClient) GetString(key string) (string, error) { return "" func (c *capturingRedisClient) SetString(key, value string, ttl int64) error { return nil } -func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) { return map[string]string{}, nil } +func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) { + return map[string]string{}, nil +} func (c *capturingRedisClient) SetHashField(key, field, value string) error { return nil } -func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error { return nil } +func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error { + c.deletedHashKey = key + c.deletedHashFields = append([]string(nil), fields...) + return nil +} -func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) { return nil, nil } +func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) { + return nil, nil +} func (c *capturingRedisClient) ListPush(key string, values ...string) error { return nil } @@ -83,7 +93,9 @@ func (c *capturingRedisClient) StreamDelete(key string, ids ...string) (int64, e func (c *capturingRedisClient) ExecuteCommand(args []string) (interface{}, error) { return nil, nil } -func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) { return map[string]string{}, nil } +func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) { + return map[string]string{}, nil +} func (c *capturingRedisClient) GetDatabases() ([]redislib.RedisDBInfo, error) { return nil, nil } @@ -109,7 +121,7 @@ func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) { testCases := []struct { - name string + name string savedConfig connection.ConnectionConfig runtimeConfig connection.ConnectionConfig assertResolved func(t *testing.T, got connection.ConnectionConfig) @@ -426,3 +438,75 @@ func TestRedisConnectRetriesLegacyDefaultRootUserWithoutUsernameAfterAuthFailure t.Fatalf("expected fallback Redis connect attempt to clear legacy root user, got %q", connectCalls[1].User) } } + +func TestRedisDeleteHashFieldAcceptsSingleStringField(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + CloseAllRedisClients() + client := &capturingRedisClient{} + originalNewRedisClientFunc := newRedisClientFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newRedisClientFunc = originalNewRedisClientFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + CloseAllRedisClients() + }() + newRedisClientFunc = func() redislib.RedisClient { + return client + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + result := app.RedisDeleteHashField(connection.ConnectionConfig{ + Type: "redis", + Host: "redis.local", + Port: 6379, + }, "profile", "nickname") + if !result.Success { + t.Fatalf("RedisDeleteHashField returned failure: %+v", result) + } + if client.deletedHashKey != "profile" { + t.Fatalf("expected hash key profile, got %q", client.deletedHashKey) + } + if len(client.deletedHashFields) != 1 || client.deletedHashFields[0] != "nickname" { + t.Fatalf("expected one deleted hash field nickname, got %v", client.deletedHashFields) + } +} + +func TestRedisDeleteHashFieldAcceptsStringSlice(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + CloseAllRedisClients() + client := &capturingRedisClient{} + originalNewRedisClientFunc := newRedisClientFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newRedisClientFunc = originalNewRedisClientFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + CloseAllRedisClients() + }() + newRedisClientFunc = func() redislib.RedisClient { + return client + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + result := app.RedisDeleteHashField(connection.ConnectionConfig{ + Type: "redis", + Host: "redis.local", + Port: 6379, + }, "profile", []string{"nickname", "avatar"}) + if !result.Success { + t.Fatalf("RedisDeleteHashField returned failure: %+v", result) + } + if client.deletedHashKey != "profile" { + t.Fatalf("expected hash key profile, got %q", client.deletedHashKey) + } + if len(client.deletedHashFields) != 2 || client.deletedHashFields[0] != "nickname" || client.deletedHashFields[1] != "avatar" { + t.Fatalf("unexpected deleted hash fields: %v", client.deletedHashFields) + } +}