🐛 fix(redis): 修正 hash 字段删除参数序列化错误

- 前端统一按数组传递 hash 字段删除参数
- 后端兼容单字符串与数组两种删除入参
- 补充 Redis hash 字段删除回归测试

Fixes #343
This commit is contained in:
Syngnat
2026-04-17 12:45:21 +08:00
parent 384aea132c
commit 22e4299d3e
4 changed files with 149 additions and 12 deletions

View File

@@ -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 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。

View File

@@ -1194,7 +1194,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ 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);

View File

@@ -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 == "<nil>" {
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()}
}

View File

@@ -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)
}
}