From 6f78d8e34167bd90a3487822f73b4f7fca6a2df7 Mon Sep 17 00:00:00 2001 From: douxu Date: Thu, 9 Jul 2026 16:31:19 +0800 Subject: [PATCH] fix: improve measurement recommend validation and offset handling - use unified response codes for measurement recommend handler - reject recommend inputs with continuous dots as invalid params - fix exact and continuation match offsets for recommend results - preserve correct offsets for fallback recommend scenarios - use hash-based confirmation for measurement token7 recommendations - write component and bay token7 hash indexes for HEXISTS validation - add tests for input validation, hash keys, and recommend offset cases --- handler/measurement_recommend.go | 34 +-- handler/measurement_recommend_test.go | 44 ++++ model/component_column_recommend_model.go | 9 + .../component_column_recommend_model_test.go | 22 ++ model/measurement_group_recommend_model.go | 3 + model/recommend_hash.go | 15 ++ model/redis_recommend.go | 121 ++++++++-- model/redis_recommend_test.go | 215 ++++++++++++++++++ 8 files changed, 427 insertions(+), 36 deletions(-) create mode 100644 handler/measurement_recommend_test.go create mode 100644 model/component_column_recommend_model_test.go create mode 100644 model/recommend_hash.go diff --git a/handler/measurement_recommend.go b/handler/measurement_recommend.go index da3972e..40ce7bd 100644 --- a/handler/measurement_recommend.go +++ b/handler/measurement_recommend.go @@ -2,7 +2,8 @@ package handler import ( - "net/http" + "errors" + "strings" "modelRT/constants" "modelRT/logger" @@ -49,9 +50,13 @@ func MeasurementRecommendHandler(c *gin.Context) { if err := c.ShouldBindQuery(&request); err != nil { logger.Error(ctx, "failed to bind measurement recommend request", "error", err) - c.JSON(http.StatusOK, network.FailureResponse{ - Code: http.StatusBadRequest, - Msg: err.Error(), + renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) + return + } + if err := validateMeasurementRecommendInput(request.Input); err != nil { + logger.Warn(ctx, "invalid measurement recommend input", "input", request.Input, "error", err) + renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), map[string]any{ + "input": request.Input, }) return } @@ -67,12 +72,8 @@ func MeasurementRecommendHandler(c *gin.Context) { if recommendResult.Err != nil { err := recommendResult.Err logger.Error(ctx, "failed to get recommend data from redis", "input", request.Input, "error", err) - c.JSON(http.StatusOK, network.FailureResponse{ - Code: http.StatusInternalServerError, - Msg: err.Error(), - Payload: map[string]any{ - "input": request.Input, - }, + renderRespFailure(c, constants.RespCodeServerError, err.Error(), map[string]any{ + "input": request.Input, }) return } @@ -91,11 +92,7 @@ func MeasurementRecommendHandler(c *gin.Context) { } } - c.JSON(http.StatusOK, network.SuccessResponse{ - Code: http.StatusOK, - Msg: "success", - Payload: payload, - }) + renderRespSuccess(c, constants.RespCodeSuccess, "success", payload) } func orderedRecommendResults(recommendResults map[string]model.SearchResult) []model.SearchResult { @@ -119,3 +116,10 @@ func orderedRecommendResults(recommendResults map[string]model.SearchResult) []m } return results } + +func validateMeasurementRecommendInput(input string) error { + if strings.Contains(input, "..") { + return errors.New("input contains continuous dots") + } + return nil +} diff --git a/handler/measurement_recommend_test.go b/handler/measurement_recommend_test.go new file mode 100644 index 0000000..1b29aeb --- /dev/null +++ b/handler/measurement_recommend_test.go @@ -0,0 +1,44 @@ +package handler + +import "testing" + +func TestValidateMeasurementRecommendInput(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + { + name: "continuous dots", + input: "G..", + valid: false, + }, + { + name: "single separator", + input: "G.zone", + valid: true, + }, + { + name: "trailing dot", + input: "G.", + valid: true, + }, + { + name: "empty input", + input: "", + valid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMeasurementRecommendInput(tt.input) + if tt.valid && err != nil { + t.Fatalf("expected valid input, got error %v", err) + } + if !tt.valid && err == nil { + t.Fatalf("expected invalid input") + } + }) + } +} diff --git a/model/component_column_recommend_model.go b/model/component_column_recommend_model.go index 78e8b8b..1d94272 100644 --- a/model/component_column_recommend_model.go +++ b/model/component_column_recommend_model.go @@ -30,6 +30,7 @@ func StoreComponentColumnRecommend(ctx context.Context, compTagToFullPath map[st for compTag, fullPath := range compTagToFullPath { specCompMeasKey := fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag) pipe.SAdd(ctx, specCompMeasKey, componentColumnMembers...) + pipe.HSet(ctx, componentColumnGroupKey(compTag), componentColumnHashFields(componentColumnNames)...) isLocalFullPath := isLocalCompTagToFullPath[compTag] fullConfigTerm := fmt.Sprintf("%s.%s", fullPath, constants.ComponentConfigKey) @@ -80,3 +81,11 @@ func stringSliceToAny(values []string) []any { } return members } + +func componentColumnGroupKey(compTag string) string { + return recommendGroupKey(compTag, constants.ComponentConfigKey) +} + +func componentColumnHashFields(columnNames []string) []any { + return recommendHashFields(columnNames) +} diff --git a/model/component_column_recommend_model_test.go b/model/component_column_recommend_model_test.go new file mode 100644 index 0000000..ce528c7 --- /dev/null +++ b/model/component_column_recommend_model_test.go @@ -0,0 +1,22 @@ +package model + +import ( + "reflect" + "testing" +) + +func TestComponentColumnGroupKey(t *testing.T) { + got := componentColumnGroupKey("cable_26-demoProject110kV_TV") + want := "cable_26-demoProject110kV_TV_component" + if got != want { + t.Fatalf("expected key %q, got %q", want, got) + } +} + +func TestComponentColumnHashFields(t *testing.T) { + got := componentColumnHashFields([]string{"global_uuid", "nspath"}) + want := []any{"global_uuid", true, "nspath", true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected fields %#v, got %#v", want, got) + } +} diff --git a/model/measurement_group_recommend_model.go b/model/measurement_group_recommend_model.go index a27736f..7985174 100644 --- a/model/measurement_group_recommend_model.go +++ b/model/measurement_group_recommend_model.go @@ -184,6 +184,9 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement sug = append(sug, redisearch.Suggestion{Term: measTerm, Score: constants.DefaultScore}) } safeSAdd(fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag), measTags) + if len(measTags) > 0 { + pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...) + } ac.AddTerms(sug...) } diff --git a/model/recommend_hash.go b/model/recommend_hash.go new file mode 100644 index 0000000..e5eea62 --- /dev/null +++ b/model/recommend_hash.go @@ -0,0 +1,15 @@ +package model + +import "fmt" + +func recommendGroupKey(compTag string, configToken string) string { + return fmt.Sprintf("%s_%s", compTag, configToken) +} + +func recommendHashFields(values []string) []any { + fields := make([]any, 0, len(values)*2) + for _, value := range values { + fields = append(fields, value, true) + } + return fields +} diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 5249872..c42c6ca 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "math" + "slices" "strings" "sync" @@ -25,7 +26,7 @@ type SearchResult struct { // input redis key, used to distinguish which goroutine the result belongs to RecommendType constants.RecommendHierarchyType QueryDatas []string - IsFuzzy bool + IsFuzzy bool // 代表本次搜索是否是模糊搜索, 例如输入 grid1. IsFallback bool Offset int Err error @@ -277,13 +278,16 @@ func normalizeRecommendResults(input string, results map[string]SearchResult) ma if result.Err != nil { continue } - if result.IsFallback { - result.Offset = maxRecommendLength(result.QueryDatas) - results[key] = result - continue - } if !result.IsFuzzy { - result.Offset = calcSearchResultOffset(input, result.QueryDatas) + if hasExactCompletion(result.QueryDatas) { + result.Offset = len([]rune(input)) + } else if hasLevelContinuation(result.QueryDatas) { + result.Offset = len([]rune(input)) + results[key] = result + continue + } else { + result.Offset = calcSearchResultOffset(input, result.QueryDatas) + } } result.QueryDatas = trimRecommendTerms(result.QueryDatas, result.Offset) results[key] = result @@ -291,6 +295,16 @@ func normalizeRecommendResults(input string, results map[string]SearchResult) ma return results } +// hasExactCompletion 判断推荐项是否表示当前输入已经完整命中, 无需再补充后缀。 +func hasExactCompletion(recommends []string) bool { + return slices.Contains(recommends, "") +} + +// hasLevelContinuation 判断推荐项是否表示当前层级已命中, 可继续补充分隔符进入下一层。 +func hasLevelContinuation(recommends []string) bool { + return slices.Contains(recommends, ".") +} + // calcSearchResultOffset 计算 input 与推荐项的最短公共前缀长度, 用作裁剪位置。 func calcSearchResultOffset(input string, recommends []string) int { var minOffset int @@ -303,15 +317,13 @@ func calcSearchResultOffset(input string, recommends []string) int { return minOffset } -// maxRecommendLength 返回推荐项最大长度, fallback 结果用它表示无需裁剪已有输入。 -func maxRecommendLength(recommends []string) int { - var maxLen int - for _, recommend := range recommends { - if length := len([]rune(recommend)); length > maxLen { - maxLen = length - } +// recommendPrefixOffset 返回推荐结果中已由输入确定的前缀长度, 包含末尾分隔符。 +func recommendPrefixOffset(prefixTokens []string) int { + prefix := strings.Join(prefixTokens, ".") + if prefix == "" { + return 0 } - return maxLen + return len([]rune(prefix)) + 1 } // trimRecommendTerms 按 offset 截掉已输入前缀, 并去掉空值和重复值。 @@ -552,6 +564,45 @@ func combineQueryResultByInput(hierarchy constants.RecommendHierarchyType, recom return recommandResults } +// confirmMeasRecommendResultsByHash 用 token5-token6 hash 确认 token7 是否属于当前配置组。 +func confirmMeasRecommendResultsByHash(ctx context.Context, rdb *redis.Client, inputSlice []string, members []string, recommends []string) []string { + compTag, configToken, ok := recommendGroupTokens(inputSlice) + if !ok { + return []string{} + } + + hashKey := recommendGroupKey(compTag, configToken) + confirmedResults := make([]string, 0, len(recommends)) + for index, recommend := range recommends { + if index >= len(members) { + break + } + + exists, err := rdb.HExists(ctx, hashKey, members[index]).Result() + if err != nil { + logger.Error(ctx, "measurement recommend hash confirmation failed", "hash_key", hashKey, "field", members[index], "error", err) + continue + } + if !exists { + continue + } + confirmedResults = append(confirmedResults, recommend) + } + return confirmedResults +} + +func recommendGroupTokens(inputSlice []string) (string, string, bool) { + searchInputIndex := len(inputSlice) - 1 + compTagIndex := searchInputIndex - 2 + configTokenIndex := searchInputIndex - 1 + if compTagIndex < 0 || configTokenIndex < 0 || configTokenIndex >= len(inputSlice) { + return "", "", false + } + compTag := inputSlice[compTagIndex] + configToken := inputSlice[configTokenIndex] + return compTag, configToken, compTag != "" && configToken != "" +} + // getSpecificKeyByLength 根据层级和父级 token 生成对应的 specific set key。 func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefix string) string { switch hierarchy { @@ -648,11 +699,30 @@ func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice [ Err: err, } } + isFallback := false + if len(recommends) == 0 { + queryKey := fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, nsPath) + members, err := rdb.SMembers(ctx, queryKey).Result() + if err != nil && err != redis.Nil { + logger.Error(ctx, "fallback measurement tags by nspath failed", "key", queryKey, "nspath", nsPath, "error", err) + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + IsFallback: true, + Err: err, + } + } + recommends = combineNSPathMeasResults(nsPath, members) + offset = recommendPrefixOffset([]string{nsPath}) + isFallback = true + } return SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, + IsFallback: isFallback, Offset: offset, Err: nil, } @@ -770,7 +840,10 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } recommandResults := combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members) - if hierarchy == constants.ConfigRecommendHierarchyType || hierarchy == constants.MeasTagRecommendHierarchyType { + if hierarchy == constants.MeasTagRecommendHierarchyType { + recommandResults = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults) + } + if hierarchy == constants.ConfigRecommendHierarchyType { // check the relevance between the config hierarchy and measurement hierarchy output and the request input, i.e., use FT.SUGGET search_suggestions_dict "recommandResult" max 1 for an exact query to check if the result exists secondConfirmResults := make([]string, 0, len(recommandResults)) for _, res := range recommandResults { @@ -854,7 +927,12 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } } isFallback = true - offset = 0 + if _, ok := fallbackSpecificSetKey(hierarchy, inputSlice); ok { + recommends = combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, recommends) + offset = recommendPrefixOffset(inputSlice[:len(inputSlice)-1]) + } else { + offset = 0 + } } return SearchResult{ @@ -871,17 +949,18 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) { var configToken string var comparePrefix string + var memberComparePrefix string searchInputLen := len([]rune(searchInput)) compareHierarchyLen := int(hierarchy) comparePrefix = searchPrefix + memberComparePrefix = searchPrefix if hierarchy == constants.MeasTagRecommendHierarchyType { - compareHierarchyLen = int(hierarchy) - 1 lastDotIndex := strings.LastIndex(searchPrefix, ".") if lastDotIndex == -1 { configToken = "" } else { configToken = searchPrefix[lastDotIndex+1:] - comparePrefix = searchPrefix[:lastDotIndex] + memberComparePrefix = searchPrefix[:lastDotIndex] } } @@ -941,7 +1020,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } if termHierarchyLen == compareHierarchyLen && termPrefix == comparePrefix && strings.HasPrefix(termLastPart, searchInput) { - exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, comparePrefix, termLastPart) + exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, memberComparePrefix, termLastPart) if err != nil || !exists { logger.Info(ctx, "fuzzy recommend term not found in specific redis set", "hierarchy", hierarchy, @@ -956,7 +1035,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } recommend := result.Term - if hierarchy == constants.MeasTagRecommendHierarchyType { + if hierarchy == constants.MeasTagRecommendHierarchyType && configToken != "" && termPrefix == memberComparePrefix { recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".") } recommends = append(recommends, recommend) diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index 3c5f4c4..88721d5 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "strings" "testing" "modelRT/constants" @@ -44,6 +45,220 @@ func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) { } } +func TestNormalizeRecommendResultsUsesInputLengthForExactCompletion(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "full token1 to token7 structure", + input: "grid000.zone000.station000.220kV_学府路1-testProject1.compTag.config.IA_rms_CTA-testProject1", + }, + { + name: "local token4 to token7 structure", + input: "220kV_学府路1-testProject1.IA_rms_CTA-testProject1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: []string{""}, + IsFuzzy: false, + }, + } + + got := normalizeRecommendResults(tt.input, results) + result := got[constants.MeasTagRecommendHierarchyType.String()] + wantOffset := len([]rune(tt.input)) + if result.Offset != wantOffset { + t.Fatalf("expected offset %d, got %d", wantOffset, result.Offset) + } + if len(result.QueryDatas) != 0 { + t.Fatalf("expected exact completion to return no suffix, got %v", result.QueryDatas) + } + }) + } +} + +func TestNormalizeRecommendResultsUsesInputLengthForLevelContinuation(t *testing.T) { + tests := []struct { + name string + input string + recommendType constants.RecommendHierarchyType + }{ + { + name: "token1 grid can continue", + input: "grid000", + recommendType: constants.GridRecommendHierarchyType, + }, + { + name: "token1 token2 zone can continue", + input: "grid000.zone000", + recommendType: constants.ZoneRecommendHierarchyType, + }, + { + name: "token1 token2 token3 station can continue", + input: "grid000.zone000.station000", + recommendType: constants.StationRecommendHierarchyType, + }, + { + name: "token1 token2 token3 token4 nspath can continue", + input: "grid000.zone000.station000.110kV_TV-demoProject", + recommendType: constants.CompNSPathRecommendHierarchyType, + }, + { + name: "token1 token2 token3 token4 token5 compTag can continue", + input: "grid000.zone000.station000.110kV_TV-demoProject.cable_26-demoProject110kV_TV", + recommendType: constants.CompTagRecommendHierarchyType, + }, + { + name: "token1 through token6 config can continue", + input: "grid000.zone000.station000.110kV_TV-demoProject.cable_26-demoProject110kV_TV.base_extend", + recommendType: constants.ConfigRecommendHierarchyType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := map[string]SearchResult{ + tt.recommendType.String(): { + RecommendType: tt.recommendType, + QueryDatas: []string{"."}, + IsFuzzy: false, + }, + } + + got := normalizeRecommendResults(tt.input, results) + result := got[tt.recommendType.String()] + wantOffset := len([]rune(tt.input)) + if result.Offset != wantOffset { + t.Fatalf("expected offset %d, got %d", wantOffset, result.Offset) + } + if len(result.QueryDatas) != 1 || result.QueryDatas[0] != "." { + t.Fatalf("expected level continuation '.', got %v", result.QueryDatas) + } + }) + } +} + +func TestNormalizeRecommendResultsKeepsFallbackOffsetZero(t *testing.T) { + input := "x" + results := map[string]SearchResult{ + constants.GridRecommendHierarchyType.String(): { + RecommendType: constants.GridRecommendHierarchyType, + QueryDatas: []string{"grid000", "grid001"}, + IsFuzzy: true, + IsFallback: true, + Offset: 0, + }, + constants.CompNSPathRecommendHierarchyType.String(): { + RecommendType: constants.CompNSPathRecommendHierarchyType, + QueryDatas: []string{"nspath000", "nspath001"}, + IsFuzzy: true, + IsFallback: true, + Offset: 0, + }, + } + + got := normalizeRecommendResults(input, results) + for key, result := range got { + if result.Offset != 0 { + t.Fatalf("expected fallback offset 0 for %s, got %d", key, result.Offset) + } + if len(result.QueryDatas) != 2 { + t.Fatalf("expected fallback recommends to remain intact for %s, got %v", key, result.QueryDatas) + } + } +} + +func TestNormalizeRecommendResultsKeepsParentPrefixOffsetForSpecificFallback(t *testing.T) { + nsPath := "220kV_学府路1-testProject1" + input := nsPath + ".x" + offset := recommendPrefixOffset([]string{nsPath}) + results := map[string]SearchResult{ + constants.CompTagRecommendHierarchyType.String(): { + RecommendType: constants.CompTagRecommendHierarchyType, + QueryDatas: []string{ + nsPath + ".CTA-testProject1220kV_学府路1", + nsPath + ".CB-testProject1220kV_学府路1", + }, + IsFuzzy: true, + IsFallback: true, + Offset: offset, + }, + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: []string{ + nsPath + ".IA_rms_CTA-testProject1", + nsPath + ".IB_rms_CTA-testProject1", + }, + IsFuzzy: true, + IsFallback: true, + Offset: offset, + }, + } + + got := normalizeRecommendResults(input, results) + if offset != 24 { + t.Fatalf("expected sample parent prefix offset 24, got %d", offset) + } + for key, result := range got { + if result.Offset != 24 { + t.Fatalf("expected specific fallback offset 24 for %s, got %d", key, result.Offset) + } + for _, recommend := range result.QueryDatas { + if strings.HasPrefix(recommend, nsPath+".") { + t.Fatalf("expected trimmed fallback recommend for %s, got %s", key, recommend) + } + } + } +} + +func TestRecommendGroupTokens(t *testing.T) { + tests := []struct { + name string + inputSlice []string + wantCompTag string + wantConfig string + wantOK bool + }{ + { + name: "full token1 to token7 input", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "base_extend", ""}, + wantCompTag: "comp_tag", + wantConfig: "base_extend", + wantOK: true, + }, + { + name: "local token4 to token7 input", + inputSlice: []string{"nspath", "comp_tag", "component", ""}, + wantCompTag: "comp_tag", + wantConfig: "component", + wantOK: true, + }, + { + name: "missing config token", + inputSlice: []string{"nspath", ""}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotCompTag, gotConfig, gotOK := recommendGroupTokens(tt.inputSlice) + if gotOK != tt.wantOK { + t.Fatalf("expected ok %t, got %t", tt.wantOK, gotOK) + } + if gotCompTag != tt.wantCompTag || gotConfig != tt.wantConfig { + t.Fatalf("expected compTag/config %q/%q, got %q/%q", tt.wantCompTag, tt.wantConfig, gotCompTag, gotConfig) + } + }) + } +} + func TestFallbackSpecificSetKey(t *testing.T) { tests := []struct { name string