From 367de31247ccb5efa3b7659edac12b93ae7ec15e Mon Sep 17 00:00:00 2001 From: douxu Date: Mon, 29 Jun 2026 14:13:58 +0800 Subject: [PATCH 01/14] fix: flatten measurement recommend response and normalize offsets - return a single measurement recommend payload instead of grouped payloads - remove recommended_type from response schema and regenerated swagger docs - normalize Redis recommend results in model layer with stable type ordering - deduplicate trimmed recommend terms and preserve the maximum response offset --- docs/docs.go | 4 -- docs/swagger.json | 4 -- docs/swagger.yaml | 3 - handler/measurement_recommend.go | 81 ++++++++++++++------------- model/redis_recommend.go | 95 ++++++++++++++++++++++++++------ network/response.go | 1 - 6 files changed, 118 insertions(+), 70 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 8aa977c..a3451d5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -524,10 +524,6 @@ const docTemplate = `{ " \"I_B_rms\"", "\"I_C_rms\"]" ] - }, - "recommended_type": { - "type": "string", - "example": "grid_tag" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 5c5dc57..14b2253 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -518,10 +518,6 @@ " \"I_B_rms\"", "\"I_C_rms\"]" ] - }, - "recommended_type": { - "type": "string", - "example": "grid_tag" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 05519e2..92cbdc8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -86,9 +86,6 @@ definitions: items: type: string type: array - recommended_type: - example: grid_tag - type: string type: object network.RealTimeDataPayload: properties: diff --git a/handler/measurement_recommend.go b/handler/measurement_recommend.go index ef79916..b3d0ab6 100644 --- a/handler/measurement_recommend.go +++ b/handler/measurement_recommend.go @@ -2,10 +2,10 @@ package handler import ( + "modelRT/constants" "modelRT/logger" "modelRT/model" "modelRT/network" - "modelRT/util" "net/http" "github.com/gin-gonic/gin" @@ -55,8 +55,14 @@ func MeasurementRecommendHandler(c *gin.Context) { } recommendResults := model.RedisSearchRecommend(c, request.Input) - payloads := make([]network.MeasurementRecommendPayload, 0, len(recommendResults)) - for _, recommendResult := range recommendResults { + payload := network.MeasurementRecommendPayload{ + Input: request.Input, + RecommendedList: make([]string, 0), + } + seen := make(map[string]struct{}) + orderedResults := orderedRecommendResults(recommendResults) + + for _, recommendResult := range orderedResults { if recommendResult.Err != nil { err := recommendResult.Err logger.Error(c, "failed to get recommend data from redis", "input", request.Input, "error", err) @@ -70,52 +76,45 @@ func MeasurementRecommendHandler(c *gin.Context) { return } - var finalOffset int - recommends := recommendResult.QueryDatas - if recommendResult.IsFuzzy { - var maxOffset int - for index, recommend := range recommends { - offset := util.GetLongestCommonPrefixLength(request.Input, recommend) - if index == 0 || offset > maxOffset { - maxOffset = offset - } - } - finalOffset = maxOffset - } else { - var minOffset int - for index, recommend := range recommends { - offset := util.GetLongestCommonPrefixLength(request.Input, recommend) - if index == 0 || offset < minOffset { - minOffset = offset - } - } - finalOffset = minOffset + if recommendResult.Offset > payload.Offset { + payload.Offset = recommendResult.Offset } + } - resultRecommends := make([]string, 0, len(recommends)) - seen := make(map[string]struct{}) - - for _, recommend := range recommends { - recommendTerm := recommend[finalOffset:] - if len(recommendTerm) != 0 { - if _, exists := seen[recommendTerm]; !exists { - seen[recommendTerm] = struct{}{} - resultRecommends = append(resultRecommends, recommendTerm) - } + for _, recommendResult := range orderedResults { + for _, recommend := range recommendResult.QueryDatas { + if _, exists := seen[recommend]; !exists { + seen[recommend] = struct{}{} + payload.RecommendedList = append(payload.RecommendedList, recommend) } } - - payloads = append(payloads, network.MeasurementRecommendPayload{ - Input: request.Input, - Offset: finalOffset, - RecommendType: recommendResult.RecommendType.String(), - RecommendedList: resultRecommends, - }) } c.JSON(http.StatusOK, network.SuccessResponse{ Code: http.StatusOK, Msg: "success", - Payload: &payloads, + Payload: payload, }) } + +func orderedRecommendResults(recommendResults map[string]model.SearchResult) []model.SearchResult { + orderedTypes := []string{ + constants.CompNSPathRecommendHierarchyType.String(), + constants.GridRecommendHierarchyType.String(), + constants.ZoneRecommendHierarchyType.String(), + constants.StationRecommendHierarchyType.String(), + constants.CompTagRecommendHierarchyType.String(), + constants.ConfigRecommendHierarchyType.String(), + constants.MeasTagRecommendHierarchyType.String(), + } + + results := make([]model.SearchResult, 0, len(recommendResults)) + for _, recommendType := range orderedTypes { + result, ok := recommendResults[recommendType] + if !ok { + continue + } + results = append(results, result) + } + return results +} diff --git a/model/redis_recommend.go b/model/redis_recommend.go index ddfef2a..809851e 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -24,6 +24,7 @@ type SearchResult struct { RecommendType constants.RecommendHierarchyType QueryDatas []string IsFuzzy bool + Offset int Err error } @@ -72,7 +73,7 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const } // process fuzzy search result - recommends, err := runFuzzySearch(ctx, rdb, searchInput, "", hierarchy, recommendLenType) + recommends, offset, err := runFuzzySearch(ctx, rdb, searchInput, "", hierarchy, recommendLenType) if err != nil { logger.Error(ctx, fmt.Sprintf("fuzzy search failed for %s hierarchical", util.GetLevelStrByRdsKey(searchRedisKey)), "search_input", searchInput, "error", err) fanInChan <- SearchResult{ @@ -89,6 +90,7 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, + Offset: offset, Err: nil, } return @@ -98,6 +100,7 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const RecommendType: hierarchy, QueryDatas: []string{}, IsFuzzy: true, + Offset: offset, Err: nil, } } @@ -105,6 +108,9 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const // RedisSearchRecommend define func of redis search by input string and return recommend results func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchResult { rdb := diagram.GetRedisClientInstance() + finalizeResults := func(results map[string]SearchResult) map[string]SearchResult { + return normalizeRecommendResults(input, results) + } if input == "" { fanInChan := make(chan SearchResult, 2) @@ -140,7 +146,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) } inputSlice := strings.Split(input, ".") @@ -189,7 +195,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) case 2: // zone tagname search go handleLevelFuzzySearch(ctx, rdb, constants.ZoneRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllZoneSetKey, inputSlice, fanInChan) @@ -205,7 +211,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) case 3: // station tanname search go handleLevelFuzzySearch(ctx, rdb, constants.StationRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllStationSetKey, inputSlice, fanInChan) @@ -222,7 +228,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) case 4: // component nspath search go handleLevelFuzzySearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompNSPathSetKey, inputSlice, fanInChan) @@ -239,7 +245,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) case 5: // component tagname search go handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice, fanInChan) @@ -254,7 +260,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) case 6: // config search go handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice, fanInChan) @@ -269,7 +275,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) case 7: // measurement tagname search go handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice, fanInChan) @@ -284,13 +290,61 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe results[result.RecommendType.String()] = result } - return results + return finalizeResults(results) default: logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen) return nil } } +func normalizeRecommendResults(input string, results map[string]SearchResult) map[string]SearchResult { + for key, result := range results { + if result.Err != nil { + continue + } + if !result.IsFuzzy { + result.Offset = calcSearchResultOffset(input, result.QueryDatas) + } + result.QueryDatas = trimRecommendTerms(result.QueryDatas, result.Offset) + results[key] = result + } + return results +} + +func calcSearchResultOffset(input string, recommends []string) int { + var minOffset int + for index, recommend := range recommends { + offset := util.GetLongestCommonPrefixLength(input, recommend) + if index == 0 || offset < minOffset { + minOffset = offset + } + } + return minOffset +} + +func trimRecommendTerms(recommends []string, offset int) []string { + resultRecommends := make([]string, 0, len(recommends)) + seen := make(map[string]struct{}) + + for _, recommend := range recommends { + if offset > len(recommend) { + continue + } + + recommendTerm := recommend[offset:] + if recommendTerm == "" { + continue + } + if _, exists := seen[recommendTerm]; exists { + continue + } + + seen[recommendTerm] = struct{}{} + resultRecommends = append(resultRecommends, recommendTerm) + } + return resultRecommends +} + func queryMemberFromSpecificsLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, keyPrefix string) ([]string, error) { queryKey := getSpecificKeyByLength(hierarchy, keyPrefix) return rdb.SMembers(ctx, queryKey).Result() @@ -486,7 +540,6 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } secondConfirmResults = append(secondConfirmResults, res) } - recommandResults = secondConfirmResults } fanInChan <- SearchResult{ @@ -528,7 +581,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } // start redis fuzzy search - recommends, err := runFuzzySearch(ctx, rdb, searchInput, searchPrefix, hierarchy, recommendLenType) + recommends, offset, err := runFuzzySearch(ctx, rdb, searchInput, searchPrefix, hierarchy, recommendLenType) if err != nil { logger.Error(ctx, "fuzzy search failed by hierarchy", "hierarchy", hierarchy, "search_input", searchInput, "error", err) fanInChan <- SearchResult{ @@ -548,12 +601,13 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, + Offset: offset, Err: nil, } } // runFuzzySearch define func to process redis fuzzy search -func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, error) { +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 searchInputLen := len(searchInput) @@ -583,7 +637,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, }) if err != nil { logger.Error(ctx, "query key by redis fuzzy search failed", "query_key", fuzzyInput, "error", err) - return nil, fmt.Errorf("redisearch suggest failed: %w", err) + return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) } if len(results) == 0 { @@ -618,13 +672,14 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } } - if recommendLenType == constants.FullRecommendLength { + switch recommendLenType { + case constants.FullRecommendLength: if result.Term == "" { termHierarchyLen = 1 } else { termHierarchyLen = strings.Count(result.Term, ".") + 1 } - } else if recommendLenType == constants.IsLocalRecommendLength { + case constants.IsLocalRecommendLength: if result.Term == "" { termHierarchyLen = 4 } else { @@ -641,7 +696,13 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } } - return recommends, nil + if len(recommends) == 0 { + searchInput = searchInput[:len(searchInput)-1] + searchInputLen = len(searchInput) + continue + } + + return recommends, len(searchInput), nil } - return []string{}, nil + return []string{}, 0, nil } diff --git a/network/response.go b/network/response.go index b111df7..10e7589 100644 --- a/network/response.go +++ b/network/response.go @@ -26,7 +26,6 @@ type WSResponse struct { type MeasurementRecommendPayload struct { Input string `json:"input" example:"transformfeeder1_220."` Offset int `json:"offset" example:"21"` - RecommendType string `json:"recommended_type" example:"grid_tag"` RecommendedList []string `json:"recommended_list" example:"[\"I_A_rms\", \"I_B_rms\",\"I_C_rms\"]"` } From 66870c70081f8ec378f066f884674e136ff81186 Mon Sep 17 00:00:00 2001 From: douxu Date: Tue, 30 Jun 2026 13:55:42 +0800 Subject: [PATCH 02/14] fix: improve measurement recommend fallback and rune offsets - fallback to all current-level candidates when fuzzy search has no result - track fallback results and calculate fallback offsets from max rune length - use rune-based prefix offsets and trimming for non-ASCII tokens - reuse local nspath filtering for component nspath recommendations --- model/redis_recommend.go | 173 ++++++++++++++++++++++++++++----------- util/string.go | 6 +- 2 files changed, 130 insertions(+), 49 deletions(-) diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 809851e..2f14aee 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -24,6 +24,7 @@ type SearchResult struct { RecommendType constants.RecommendHierarchyType QueryDatas []string IsFuzzy bool + IsFallback bool Offset int Err error } @@ -96,11 +97,25 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const return } + recommends, err = fallbackAllCurrentLevel(ctx, rdb, hierarchy) + if err != nil { + logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + IsFallback: true, + Err: err, + } + return + } + fanInChan <- SearchResult{ RecommendType: hierarchy, - QueryDatas: []string{}, + QueryDatas: recommends, IsFuzzy: true, - Offset: offset, + IsFallback: true, + Offset: 0, Err: nil, } } @@ -127,21 +142,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe } if result.RecommendType == constants.CompNSPathRecommendHierarchyType { - filterResults := make([]string, 0, len(result.QueryDatas)) - // TODO 增加 nspath 过滤 - for _, queryData := range result.QueryDatas { - var nsPath string - if lastDotIndex := strings.LastIndex(queryData, "."); lastDotIndex == -1 { - nsPath = queryData - } else { - nsPath = queryData[lastDotIndex+1:] - } - - if isLocal, ok := NSPathToIsLocalMap[nsPath]; ok && isLocal { - filterResults = append(filterResults, queryData) - } - } - result.QueryDatas = filterResults + result.QueryDatas = filterLocalNSPathResults(result.QueryDatas) } results[result.RecommendType.String()] = result } @@ -171,26 +172,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe } if result.RecommendType == constants.CompNSPathRecommendHierarchyType { - filterResults := make([]string, 0, len(result.QueryDatas)) - // TODO 增加 nspath 过滤 - for _, queryData := range result.QueryDatas { - if queryData == "." { - filterResults = append(filterResults, queryData) - continue - } - - var nsPath string - if lastDotIndex := strings.LastIndex(queryData, "."); lastDotIndex == -1 { - nsPath = queryData - } else { - nsPath = queryData[lastDotIndex+1:] - } - - if isLocal, ok := NSPathToIsLocalMap[nsPath]; ok && isLocal { - filterResults = append(filterResults, queryData) - } - } - result.QueryDatas = filterResults + result.QueryDatas = filterLocalNSPathResults(result.QueryDatas) } results[result.RecommendType.String()] = result } @@ -302,6 +284,11 @@ 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) } @@ -322,16 +309,27 @@ func calcSearchResultOffset(input string, recommends []string) int { return minOffset } +func maxRecommendLength(recommends []string) int { + var maxLen int + for _, recommend := range recommends { + if length := len([]rune(recommend)); length > maxLen { + maxLen = length + } + } + return maxLen +} + func trimRecommendTerms(recommends []string, offset int) []string { resultRecommends := make([]string, 0, len(recommends)) seen := make(map[string]struct{}) for _, recommend := range recommends { - if offset > len(recommend) { + recommendRunes := []rune(recommend) + if offset > len(recommendRunes) { continue } - recommendTerm := recommend[offset:] + recommendTerm := string(recommendRunes[offset:]) if recommendTerm == "" { continue } @@ -345,11 +343,68 @@ func trimRecommendTerms(recommends []string, offset int) []string { return resultRecommends } +func filterLocalNSPathResults(queryDatas []string) []string { + filterResults := make([]string, 0, len(queryDatas)) + for _, queryData := range queryDatas { + if queryData == "." { + filterResults = append(filterResults, queryData) + continue + } + + var nsPath string + if lastDotIndex := strings.LastIndex(queryData, "."); lastDotIndex == -1 { + nsPath = queryData + } else { + nsPath = queryData[lastDotIndex+1:] + } + + // TODO 考虑为 NSPathToIsLocalMap 增加读写锁或改为并发安全快照, 保证过滤数据一致性 + if isLocal, ok := NSPathToIsLocalMap[nsPath]; ok && isLocal { + filterResults = append(filterResults, queryData) + } + } + return filterResults +} + func queryMemberFromSpecificsLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, keyPrefix string) ([]string, error) { queryKey := getSpecificKeyByLength(hierarchy, keyPrefix) return rdb.SMembers(ctx, queryKey).Result() } +func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType) ([]string, error) { + setKey, ok := getAllSetKeyByHierarchy(hierarchy) + if !ok { + return nil, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) + } + + members, err := rdb.SMembers(ctx, setKey).Result() + if err != nil && err != redis.Nil { + return nil, err + } + return members, nil +} + +func getAllSetKeyByHierarchy(hierarchy constants.RecommendHierarchyType) (string, bool) { + switch hierarchy { + case constants.GridRecommendHierarchyType: + return constants.RedisAllGridSetKey, true + case constants.ZoneRecommendHierarchyType: + return constants.RedisAllZoneSetKey, true + case constants.StationRecommendHierarchyType: + return constants.RedisAllStationSetKey, true + case constants.CompNSPathRecommendHierarchyType: + return constants.RedisAllCompNSPathSetKey, true + case constants.CompTagRecommendHierarchyType: + return constants.RedisAllCompTagSetKey, true + case constants.ConfigRecommendHierarchyType: + return constants.RedisAllConfigSetKey, true + case constants.MeasTagRecommendHierarchyType: + return constants.RedisAllMeasTagSetKey, true + default: + return "", false + } +} + func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { setKey := constants.RedisAllGridSetKey hierarchy := constants.GridRecommendHierarchyType @@ -593,14 +648,30 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co return } + isFallback := false if len(recommends) == 0 { logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err) + recommends, err = fallbackAllCurrentLevel(ctx, rdb, hierarchy) + if err != nil { + logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + IsFallback: true, + Err: err, + } + return + } + isFallback = true + offset = 0 } fanInChan <- SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, + IsFallback: isFallback, Offset: offset, Err: nil, } @@ -610,7 +681,7 @@ 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 - searchInputLen := len(searchInput) + searchInputLen := len([]rune(searchInput)) compareHierarchyLen := int(hierarchy) comparePrefix = searchPrefix if hierarchy == constants.MeasTagRecommendHierarchyType { @@ -641,10 +712,10 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } if len(results) == 0 { - // 如果没有结果,退一步(删除最后一个字节)并继续循环 - // TODO 考虑使用其他方式代替 for 循环退一字节的查询方式 - searchInput = searchInput[:len(searchInput)-1] - searchInputLen = len(searchInput) + // 如果没有结果,退一步(删除最后一个字符)并继续循环 + // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 + searchInput = trimLastRune(searchInput) + searchInputLen = len([]rune(searchInput)) continue } @@ -697,12 +768,20 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } if len(recommends) == 0 { - searchInput = searchInput[:len(searchInput)-1] - searchInputLen = len(searchInput) + searchInput = trimLastRune(searchInput) + searchInputLen = len([]rune(searchInput)) continue } - return recommends, len(searchInput), nil + return recommends, len([]rune(searchInput)), nil } return []string{}, 0, nil } + +func trimLastRune(s string) string { + runes := []rune(s) + if len(runes) == 0 { + return s + } + return string(runes[:len(runes)-1]) +} diff --git a/util/string.go b/util/string.go index d01c098..07e25d4 100644 --- a/util/string.go +++ b/util/string.go @@ -46,10 +46,12 @@ func GetLongestCommonPrefixLength(query string, result string) int { return 0 } - minLen := min(len(query), len(result)) + queryRunes := []rune(query) + resultRunes := []rune(result) + minLen := min(len(queryRunes), len(resultRunes)) for i := range minLen { - if query[i] != result[i] { + if queryRunes[i] != resultRunes[i] { return i } } From 491c10e8c57460f2a81bd101cfa8862d2355a661 Mon Sep 17 00:00:00 2001 From: douxu Date: Wed, 1 Jul 2026 14:37:13 +0800 Subject: [PATCH 03/14] feat: add component column recommendations for config tokens - add component as a token6 config recommendation constant - read component table column names during startup - bind component config recommendations to component table columns - add full and local autocomplete terms for component column paths --- constants/recommend_keys.go | 2 + database/query_component_columns.go | 28 ++++++++ main.go | 12 ++++ model/component_column_recommend_model.go | 82 +++++++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 database/query_component_columns.go create mode 100644 model/component_column_recommend_model.go diff --git a/constants/recommend_keys.go b/constants/recommend_keys.go index 2fde23f..cef6ff9 100644 --- a/constants/recommend_keys.go +++ b/constants/recommend_keys.go @@ -4,6 +4,8 @@ package constants const ( // DefaultScore define the default score for redissearch suggestion DefaultScore = 1.0 + // ComponentConfigKey define component config token used at token6 + ComponentConfigKey = "component" ) const ( diff --git a/database/query_component_columns.go b/database/query_component_columns.go new file mode 100644 index 0000000..6b7a2eb --- /dev/null +++ b/database/query_component_columns.go @@ -0,0 +1,28 @@ +// Package database define database operation functions +package database + +import ( + "context" + + "modelRT/orm" + + "gorm.io/gorm" +) + +// QueryComponentColumnNames returns all column names from the component table. +func QueryComponentColumnNames(ctx context.Context, db *gorm.DB) ([]string, error) { + columnTypes, err := db.WithContext(ctx).Migrator().ColumnTypes((&orm.Component{}).TableName()) + if err != nil { + return nil, err + } + + columnNames := make([]string, 0, len(columnTypes)) + for _, columnType := range columnTypes { + columnName := columnType.Name() + if columnName == "" { + continue + } + columnNames = append(columnNames, columnName) + } + return columnNames, nil +} diff --git a/main.go b/main.go index 84fa153..875ccc0 100644 --- a/main.go +++ b/main.go @@ -235,6 +235,18 @@ func main() { panic(err) } + componentColumnNames, err := database.QueryComponentColumnNames(ctx, tx) + if err != nil { + logger.Error(ctx, "query component table column names failed", "error", err) + panic(err) + } + + err = model.StoreComponentColumnRecommend(ctx, fullParentPath, isLocalParentPath, componentColumnNames) + if err != nil { + logger.Error(ctx, "store component column recommend content failed", "error", err) + panic(err) + } + allMeasurement, err := database.GetAllMeasurements(ctx, tx) if err != nil { logger.Error(ctx, "load topologic info from postgres failed", "error", err) diff --git a/model/component_column_recommend_model.go b/model/component_column_recommend_model.go new file mode 100644 index 0000000..78e8b8b --- /dev/null +++ b/model/component_column_recommend_model.go @@ -0,0 +1,82 @@ +// Package model define model struct of model runtime service +package model + +import ( + "context" + "fmt" + + "modelRT/constants" + "modelRT/diagram" + + "github.com/RediSearch/redisearch-go/v2/redisearch" +) + +// StoreComponentColumnRecommend binds token6 component config to component table column names. +func StoreComponentColumnRecommend(ctx context.Context, compTagToFullPath map[string]string, isLocalCompTagToFullPath map[string]string, componentColumnNames []string) error { + rdb := diagram.GetRedisClientInstance() + pipe := rdb.Pipeline() + + pipe.SAdd(ctx, constants.RedisAllConfigSetKey, constants.ComponentConfigKey) + + if len(componentColumnNames) == 0 { + _, err := pipe.Exec(ctx) + return err + } + + componentColumnMembers := stringSliceToAny(componentColumnNames) + pipe.SAdd(ctx, constants.RedisAllMeasTagSetKey, componentColumnMembers...) + + sug := make([]redisearch.Suggestion, 0, len(compTagToFullPath)*len(componentColumnNames)*4) + for compTag, fullPath := range compTagToFullPath { + specCompMeasKey := fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag) + pipe.SAdd(ctx, specCompMeasKey, componentColumnMembers...) + + isLocalFullPath := isLocalCompTagToFullPath[compTag] + fullConfigTerm := fmt.Sprintf("%s.%s", fullPath, constants.ComponentConfigKey) + sug = append(sug, redisearch.Suggestion{ + Term: fullConfigTerm, + Score: constants.DefaultScore, + }) + + if isLocalFullPath != "" { + localConfigTerm := fmt.Sprintf("%s.%s", isLocalFullPath, constants.ComponentConfigKey) + sug = append(sug, redisearch.Suggestion{ + Term: localConfigTerm, + Score: constants.DefaultScore, + }) + } + + for _, columnName := range componentColumnNames { + fullColumnTerm := fmt.Sprintf("%s.%s.%s", fullPath, constants.ComponentConfigKey, columnName) + sug = append(sug, redisearch.Suggestion{ + Term: fullColumnTerm, + Score: constants.DefaultScore, + }) + + if isLocalFullPath == "" { + continue + } + + localColumnTerm := fmt.Sprintf("%s.%s.%s", isLocalFullPath, constants.ComponentConfigKey, columnName) + sug = append(sug, redisearch.Suggestion{ + Term: localColumnTerm, + Score: constants.DefaultScore, + }) + } + } + + if len(sug) > 0 { + ac.AddTerms(sug...) + } + + _, err := pipe.Exec(ctx) + return err +} + +func stringSliceToAny(values []string) []any { + members := make([]any, 0, len(values)) + for _, value := range values { + members = append(members, value) + } + return members +} From 33eb2d9be825dd79ca2aafbf8aa2f5ba7b3bca1b Mon Sep 17 00:00:00 2001 From: douxu Date: Thu, 2 Jul 2026 13:49:47 +0800 Subject: [PATCH 04/14] feat: support nspath measurement shorthand recommendations - add nspath-to-measurement recommend set keys and cleanup pattern - collect nspath measurement tags when component nspath matches bay tag - store token4.token7 autocomplete terms during measurement recommend init - add token4.token7 recommendations alongside existing two-token paths --- constants/recommend_keys.go | 3 + database/query_component_measurement.go | 11 +- model/measurement_group_recommend_model.go | 13 ++ model/recommend_cleanup.go | 11 +- model/redis_recommend.go | 149 ++++++++++++++++++++- orm/measurement_set.go | 1 + 6 files changed, 180 insertions(+), 8 deletions(-) diff --git a/constants/recommend_keys.go b/constants/recommend_keys.go index cef6ff9..de2e443 100644 --- a/constants/recommend_keys.go +++ b/constants/recommend_keys.go @@ -44,6 +44,9 @@ const ( // RedisSpecCompTagMeasSetKey define redis set key which store all measurement tag keys under specific component tag RedisSpecCompTagMeasSetKey = "%s_measurement_tag_keys" + + // RedisSpecCompNSPathMeasSetKey define redis set key which store all measurement tag keys under specific component nspath + RedisSpecCompNSPathMeasSetKey = "%s_nspath_measurement_tag_keys" ) const ( diff --git a/database/query_component_measurement.go b/database/query_component_measurement.go index e9e95ca..ffdba8a 100644 --- a/database/query_component_measurement.go +++ b/database/query_component_measurement.go @@ -28,6 +28,7 @@ func GetFullMeasurementSet(ctx context.Context, db *gorm.DB) (*orm.MeasurementSe StationToCompNSPaths: make(map[string][]string), CompNSPathToCompTags: make(map[string][]string), CompTagToMeasTags: make(map[string][]string), + CompNSPathToMeasTags: make(map[string][]string), } g, gctx := errgroup.WithContext(ctx) @@ -113,11 +114,14 @@ func GetFullMeasurementSet(ctx context.Context, db *gorm.DB) (*orm.MeasurementSe g.Go(func() error { var measurements []struct { orm.Measurement - CompTag string `gorm:"column:comp_tag"` + CompTag string `gorm:"column:comp_tag"` + CompNSPath string `gorm:"column:comp_nspath"` + BayTag string `gorm:"column:bay_tag"` } if err := db.Table("measurement"). - Select("measurement.*, component.tag as comp_tag"). + Select("measurement.*, component.tag as comp_tag, component.nspath as comp_nspath, bay.tag as bay_tag"). Joins("left join component on measurement.component_uuid = component.global_uuid"). + Joins("left join bay on measurement.bay_uuid = bay.bay_uuid"). Scan(&measurements).Error; err != nil { return fmt.Errorf("query measurements: %w", err) } @@ -126,6 +130,9 @@ func GetFullMeasurementSet(ctx context.Context, db *gorm.DB) (*orm.MeasurementSe if m.CompTag != "" { mSet.CompTagToMeasTags[m.CompTag] = append(mSet.CompTagToMeasTags[m.CompTag], m.Tag) } + if m.CompNSPath != "" && m.CompNSPath == m.BayTag { + mSet.CompNSPathToMeasTags[m.CompNSPath] = append(mSet.CompNSPathToMeasTags[m.CompNSPath], m.Tag) + } } return nil }) diff --git a/model/measurement_group_recommend_model.go b/model/measurement_group_recommend_model.go index 02babc8..a27736f 100644 --- a/model/measurement_group_recommend_model.go +++ b/model/measurement_group_recommend_model.go @@ -187,6 +187,19 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement ac.AddTerms(sug...) } + // building the component nspath -> measurement tags hierarchy for token4-token7 shorthand + for compNSPath, measTags := range measSet.CompNSPathToMeasTags { + sug := make([]redisearch.Suggestion, 0, len(measTags)) + for _, measTag := range measTags { + sug = append(sug, redisearch.Suggestion{ + Term: fmt.Sprintf("%s.%s", compNSPath, measTag), + Score: constants.DefaultScore, + }) + } + safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, compNSPath), measTags) + ac.AddTerms(sug...) + } + var allErrs []error cmders, execErr := pipe.Exec(ctx) if execErr != nil { diff --git a/model/recommend_cleanup.go b/model/recommend_cleanup.go index 519f781..df310d9 100644 --- a/model/recommend_cleanup.go +++ b/model/recommend_cleanup.go @@ -48,11 +48,12 @@ func CleanupRecommendRedisCache(ctx context.Context) error { } patterns := []string{ - "*_zone_tag_keys", // correspond RedisSpecGridZoneSetKey - "*_station_tag_keys", // correspond RedisSpecZoneStationSetKey - "*_component_nspath_keys", // correspond RedisSpecStationCompNSPATHSetKey - "*_component_tag_keys", // correspond RedisSpecCompNSPathCompTagSetKey - "*_measurement_tag_keys", // correspond RedisSpecCompTagMeasSetKey + "*_zone_tag_keys", // correspond RedisSpecGridZoneSetKey + "*_station_tag_keys", // correspond RedisSpecZoneStationSetKey + "*_component_nspath_keys", // correspond RedisSpecStationCompNSPATHSetKey + "*_component_tag_keys", // correspond RedisSpecCompNSPathCompTagSetKey + "*_measurement_tag_keys", // correspond RedisSpecCompTagMeasSetKey + "*_nspath_measurement_tag_keys", // correspond RedisSpecCompNSPathMeasSetKey } for _, pattern := range patterns { diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 2f14aee..25d37e0 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -183,8 +183,10 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe go handleLevelFuzzySearch(ctx, rdb, constants.ZoneRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllZoneSetKey, inputSlice, fanInChan) // component tagname search go handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllCompTagSetKey, inputSlice, fanInChan) + // measurement tagname search by token4-token7 shorthand + go handleNSPathMeasSearch(ctx, rdb, inputSlice, fanInChan) results := make(map[string]SearchResult) - for range 2 { + for range 3 { result := <-fanInChan if result.Err != nil { logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) @@ -518,6 +520,151 @@ func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefi } } +func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice []string, fanInChan chan SearchResult) { + hierarchy := constants.MeasTagRecommendHierarchyType + nsPath := inputSlice[0] + searchInput := inputSlice[1] + + nsPathExists, err := rdb.SIsMember(ctx, constants.RedisAllCompNSPathSetKey, nsPath).Result() + if err != nil { + logger.Error(ctx, "check nspath exist from redis set failed", "key", constants.RedisAllCompNSPathSetKey, "nspath", nsPath, "error", err) + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: false, + Err: err, + } + return + } + if !nsPathExists { + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: []string{}, + IsFuzzy: false, + Err: nil, + } + return + } + + queryKey := fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, nsPath) + if searchInput == "" { + members, err := rdb.SMembers(ctx, queryKey).Result() + if err != nil && err != redis.Nil { + logger.Error(ctx, "query measurement tags by nspath failed", "key", queryKey, "nspath", nsPath, "error", err) + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: false, + Err: err, + } + return + } + + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: combineNSPathMeasResults(nsPath, members), + IsFuzzy: false, + Err: nil, + } + return + } + + measTagExists, err := rdb.SIsMember(ctx, queryKey, searchInput).Result() + if err != nil { + logger.Error(ctx, "check measurement tag exist from nspath set failed", "key", queryKey, "meas_tag", searchInput, "error", err) + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: false, + Err: err, + } + return + } + if measTagExists { + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: []string{""}, + IsFuzzy: false, + Err: nil, + } + return + } + + recommends, offset, err := runNSPathMeasFuzzySearch(ctx, nsPath, searchInput) + if err != nil { + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + Err: err, + } + return + } + + fanInChan <- SearchResult{ + RecommendType: hierarchy, + QueryDatas: recommends, + IsFuzzy: true, + Offset: offset, + Err: nil, + } +} + +func combineNSPathMeasResults(nsPath string, measTags []string) []string { + results := make([]string, 0, len(measTags)) + for _, measTag := range measTags { + results = append(results, fmt.Sprintf("%s.%s", nsPath, measTag)) + } + return results +} + +func runNSPathMeasFuzzySearch(ctx context.Context, nsPath string, searchInput string) ([]string, int, error) { + searchInputLen := len([]rune(searchInput)) + for searchInputLen != 0 { + fuzzyInput := fmt.Sprintf("%s.%s", nsPath, searchInput) + results, err := ac.SuggestOpts(fuzzyInput, redisearch.SuggestOptions{ + Num: math.MaxInt16, + Fuzzy: true, + WithScores: false, + WithPayloads: false, + }) + if err != nil { + logger.Error(ctx, "query measurement tag by nspath fuzzy search failed", "query_key", fuzzyInput, "error", err) + return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) + } + + if len(results) == 0 { + searchInput = trimLastRune(searchInput) + searchInputLen = len([]rune(searchInput)) + continue + } + + recommends := make([]string, 0, len(results)) + for _, result := range results { + termPrefix, termLastPart := splitLastToken(result.Term) + if termPrefix == nsPath && strings.HasPrefix(termLastPart, searchInput) { + recommends = append(recommends, result.Term) + } + } + if len(recommends) == 0 { + searchInput = trimLastRune(searchInput) + searchInputLen = len([]rune(searchInput)) + continue + } + + return recommends, len([]rune(fmt.Sprintf("%s.%s", nsPath, searchInput))), nil + } + return []string{}, 0, nil +} + +func splitLastToken(term string) (string, string) { + lastDotIndex := strings.LastIndex(term, ".") + if lastDotIndex == -1 { + return "", term + } + return term[:lastDotIndex], term[lastDotIndex+1:] +} + // handleLevelFuzzySearch define func to process recommendation logic for specific levels(level >= 2) func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, redisSetKey string, inputSlice []string, fanInChan chan SearchResult) { inputSliceLen := len(inputSlice) diff --git a/orm/measurement_set.go b/orm/measurement_set.go index 1eb2b8f..992a981 100644 --- a/orm/measurement_set.go +++ b/orm/measurement_set.go @@ -16,4 +16,5 @@ type MeasurementSet struct { StationToCompNSPaths map[string][]string // Key: StationTag, Value: NSPaths CompNSPathToCompTags map[string][]string // Key: NSPaths, Value: CompTags CompTagToMeasTags map[string][]string // Key: CompTag, Value: MeasTags + CompNSPathToMeasTags map[string][]string // Key: NSPaths, Value: MeasTags } From 305bdd4dcfa183561b4fa4a2657ef0e914707a71 Mon Sep 17 00:00:00 2001 From: douxu Date: Fri, 3 Jul 2026 15:59:53 +0800 Subject: [PATCH 05/14] fix: suppress fallback recommendations when concrete hits exist - detect concrete recommend hits before normalizing results - drop fallback results when any syntax path returns real candidates - keep fallback candidates only when every path has no concrete hit --- model/redis_recommend.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 25d37e0..5299c96 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -124,6 +124,7 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchResult { rdb := diagram.GetRedisClientInstance() finalizeResults := func(results map[string]SearchResult) map[string]SearchResult { + results = suppressFallbackResultsWhenConcreteHitExists(results) return normalizeRecommendResults(input, results) } @@ -281,6 +282,27 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe } } +func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult { + hasConcreteHit := false + for _, result := range results { + if result.Err == nil && !result.IsFallback && len(result.QueryDatas) > 0 { + hasConcreteHit = true + break + } + } + + if !hasConcreteHit { + return results + } + + for key, result := range results { + if result.IsFallback { + delete(results, key) + } + } + return results +} + func normalizeRecommendResults(input string, results map[string]SearchResult) map[string]SearchResult { for key, result := range results { if result.Err != nil { From b53746efcd181daad60c2956624b52adb1b2f9c1 Mon Sep 17 00:00:00 2001 From: douxu Date: Tue, 7 Jul 2026 17:11:25 +0800 Subject: [PATCH 06/14] fix: improve redis recommendation fuzzy matching - add bounded errgroup handling for attribute group suggestion initialization - add config-level suggestions and propagate Redis/RediSearch write errors - constrain fuzzy recommendation candidates by hierarchy-specific Redis sets - avoid broad fallback results when concrete matches exist - preserve full matched prefix when calculating fuzzy recommendation offset - add tests for fuzzy recommendation offset calculation --- model/attribute_group_recommend_model.go | 75 +++++++++--- model/redis_recommend.go | 150 +++++++++++++++++++++-- model/redis_recommend_test.go | 40 ++++++ 3 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 model/redis_recommend_test.go diff --git a/model/attribute_group_recommend_model.go b/model/attribute_group_recommend_model.go index 2db4baa..22253df 100644 --- a/model/attribute_group_recommend_model.go +++ b/model/attribute_group_recommend_model.go @@ -12,6 +12,7 @@ import ( "modelRT/orm" "github.com/RediSearch/redisearch-go/v2/redisearch" + "golang.org/x/sync/errgroup" "gorm.io/gorm" ) @@ -20,6 +21,13 @@ type columnParam struct { AttributeGroup map[string]any } +type attributeGroupRecommendJob struct { + AttributeSet orm.AttributeSet + FullPath string + IsLocalFullPath string + ColumnParam columnParam +} + // TraverseAttributeGroupTables define func to traverse component attribute group tables func TraverseAttributeGroupTables(ctx context.Context, db *gorm.DB, compTagToFullPath map[string]string, isLocalCompTagToFullPath map[string]string, compAttrSet map[string]orm.AttributeSet) error { var tableNames []string @@ -38,6 +46,7 @@ func TraverseAttributeGroupTables(ctx context.Context, db *gorm.DB, compTagToFul return nil } + jobs := make([]attributeGroupRecommendJob, 0) for _, tableName := range tableNames { var records []map[string]any err := db.Table(tableName).Find(&records).Error @@ -102,13 +111,27 @@ func TraverseAttributeGroupTables(ctx context.Context, db *gorm.DB, compTagToFul AttributeType: attributeType, AttributeGroup: attributeGroup, } - go storeAttributeGroup(ctx, attrSet, fullPath, isLocalfullPath, columnParam) + jobs = append(jobs, attributeGroupRecommendJob{ + AttributeSet: attrSet, + FullPath: fullPath, + IsLocalFullPath: isLocalfullPath, + ColumnParam: columnParam, + }) } } - return nil + + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(16) + for _, job := range jobs { + job := job + group.Go(func() error { + return storeAttributeGroup(groupCtx, job.AttributeSet, job.FullPath, job.IsLocalFullPath, job.ColumnParam) + }) + } + return group.Wait() } -func storeAttributeGroup(ctx context.Context, attributeSet orm.AttributeSet, fullPath string, isLocalFullPath string, colParams columnParam) { +func storeAttributeGroup(ctx context.Context, attributeSet orm.AttributeSet, fullPath string, isLocalFullPath string, colParams columnParam) error { rdb := diagram.GetRedisClientInstance() pipe := rdb.Pipeline() @@ -121,18 +144,25 @@ func storeAttributeGroup(ctx context.Context, attributeSet orm.AttributeSet, ful attrbutesGroups := make([]any, 0, len(colParams.AttributeGroup)*2) attributeGroupKey := fmt.Sprintf("%s_%s", attributeSet.CompTag, colParams.AttributeType) - sug := make([]redisearch.Suggestion, 0, len(colParams.AttributeGroup)*4) + sug := make([]redisearch.Suggestion, 0, len(colParams.AttributeGroup)*2+2) + configTerm := fmt.Sprintf("%s.%s", fullPath, colParams.AttributeType) + sug = append(sug, redisearch.Suggestion{ + Term: configTerm, + Score: constants.DefaultScore, + }) + if isLocalFullPath != "" { + configTerm = fmt.Sprintf("%s.%s", isLocalFullPath, colParams.AttributeType) + sug = append(sug, redisearch.Suggestion{ + Term: configTerm, + Score: constants.DefaultScore, + }) + } + for attrName, attrValue := range colParams.AttributeGroup { attrbutesGroups = append(attrbutesGroups, attrName, attrValue) attrNameMembers = append(attrNameMembers, attrName) // add redis fuzzy search suggestion for token1-token7 type - configTerm := fmt.Sprintf("%s.%s", fullPath, colParams.AttributeType) - sug = append(sug, redisearch.Suggestion{ - Term: configTerm, - Score: constants.DefaultScore, - }) - measTerm := fmt.Sprintf("%s.%s.%s", fullPath, colParams.AttributeType, attrName) sug = append(sug, redisearch.Suggestion{ Term: measTerm, @@ -140,11 +170,9 @@ func storeAttributeGroup(ctx context.Context, attributeSet orm.AttributeSet, ful }) // add redis fuzzy search suggestion for token4-token7 type - configTerm = fmt.Sprintf("%s.%s", isLocalFullPath, colParams.AttributeType) - sug = append(sug, redisearch.Suggestion{ - Term: configTerm, - Score: constants.DefaultScore, - }) + if isLocalFullPath == "" { + continue + } measTerm = fmt.Sprintf("%s.%s.%s", isLocalFullPath, colParams.AttributeType, attrName) sug = append(sug, redisearch.Suggestion{ @@ -163,11 +191,24 @@ func storeAttributeGroup(ctx context.Context, attributeSet orm.AttributeSet, ful } if len(sug) > 0 { - ac.AddTerms(sug...) + if err := ac.AddTerms(sug...); err != nil { + logger.Error(ctx, "add attribute group recommend suggestions failed", + "component_tag", attributeSet.CompTag, + "attribute_type", colParams.AttributeType, + "error", err, + ) + return fmt.Errorf("add attribute group recommend suggestions: %w", err) + } } _, err := pipe.Exec(ctx) if err != nil { - logger.Error(ctx, "init component attribute group recommend content failed", "error", err) + logger.Error(ctx, "init component attribute group recommend content failed", + "component_tag", attributeSet.CompTag, + "attribute_type", colParams.AttributeType, + "error", err, + ) + return fmt.Errorf("init component attribute group recommend content: %w", err) } + return nil } diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 5299c96..fa0db57 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -36,6 +36,7 @@ func InitAutocompleterWithPool(pool *redigo.Pool) { ac = redisearch.NewAutocompleterFromPool(pool, constants.RedisSearchDictName) } +// levelOneRedisSearch 处理第一段输入的搜索, 例如 grid 或本地 nspath。 func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, searchInput string, searchRedisKey string, fanInChan chan SearchResult) { defer func() { if r := recover(); r != nil { @@ -282,6 +283,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe } } +// suppressFallbackResultsWhenConcreteHitExists 有明确命中时丢弃兜底结果, 避免返回过宽推荐。 func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult { hasConcreteHit := false for _, result := range results { @@ -303,6 +305,7 @@ func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResul return results } +// normalizeRecommendResults 统一处理 offset、去重和裁剪, 让响应只返回用户还没输入的部分。 func normalizeRecommendResults(input string, results map[string]SearchResult) map[string]SearchResult { for key, result := range results { if result.Err != nil { @@ -322,6 +325,7 @@ func normalizeRecommendResults(input string, results map[string]SearchResult) ma return results } +// calcSearchResultOffset 计算 input 与推荐项的最短公共前缀长度, 用作裁剪位置。 func calcSearchResultOffset(input string, recommends []string) int { var minOffset int for index, recommend := range recommends { @@ -333,6 +337,7 @@ func calcSearchResultOffset(input string, recommends []string) int { return minOffset } +// maxRecommendLength 返回推荐项最大长度, fallback 结果用它表示无需裁剪已有输入。 func maxRecommendLength(recommends []string) int { var maxLen int for _, recommend := range recommends { @@ -343,6 +348,7 @@ func maxRecommendLength(recommends []string) int { return maxLen } +// trimRecommendTerms 按 offset 截掉已输入前缀, 并去掉空值和重复值。 func trimRecommendTerms(recommends []string, offset int) []string { resultRecommends := make([]string, 0, len(recommends)) seen := make(map[string]struct{}) @@ -367,6 +373,7 @@ func trimRecommendTerms(recommends []string, offset int) []string { return resultRecommends } +// filterLocalNSPathResults 只保留本地组件 nspath, 避免远端 nspath 进入本地简写推荐。 func filterLocalNSPathResults(queryDatas []string) []string { filterResults := make([]string, 0, len(queryDatas)) for _, queryData := range queryDatas { @@ -390,11 +397,13 @@ func filterLocalNSPathResults(queryDatas []string) []string { return filterResults } +// queryMemberFromSpecificsLevel 根据上一层级 token 查询其真实下级集合。 func queryMemberFromSpecificsLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, keyPrefix string) ([]string, error) { queryKey := getSpecificKeyByLength(hierarchy, keyPrefix) return rdb.SMembers(ctx, queryKey).Result() } +// fallbackAllCurrentLevel 兜底返回当前层级的全量集合。 func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType) ([]string, error) { setKey, ok := getAllSetKeyByHierarchy(hierarchy) if !ok { @@ -408,6 +417,61 @@ func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy c return members, nil } +// fallbackCurrentLevelByInput 兜底时先校验上下级关系, 避免错误前缀返回全层级数据。 +func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, inputSlice []string) ([]string, error) { + allSetKey, ok := getAllSetKeyByHierarchy(hierarchy) + if !ok { + return nil, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) + } + + specificSetKey, ok := fallbackSpecificSetKey(hierarchy, inputSlice) + if !ok { + return fallbackAllCurrentLevel(ctx, rdb, hierarchy) + } + + count, err := rdb.SInterCard(ctx, 1, allSetKey, specificSetKey).Result() + if err != nil && err != redis.Nil { + return nil, err + } + if count == 0 { + return []string{}, nil + } + + members, err := rdb.SMembers(ctx, specificSetKey).Result() + if err != nil && err != redis.Nil { + return nil, err + } + return members, nil +} + +// fallbackSpecificSetKey 根据输入切片推导 fallback 应该校验的上一层级 specific set。 +func fallbackSpecificSetKey(hierarchy constants.RecommendHierarchyType, inputSlice []string) (string, bool) { + if len(inputSlice) == 0 { + return "", false + } + + searchInputIndex := len(inputSlice) - 1 + specificKeyIndex := searchInputIndex - 1 + if hierarchy == constants.MeasTagRecommendHierarchyType { + specificKeyIndex = searchInputIndex - 2 + } + if specificKeyIndex < 0 || specificKeyIndex >= len(inputSlice) || inputSlice[specificKeyIndex] == "" { + return "", false + } + + switch hierarchy { + case constants.ZoneRecommendHierarchyType, + constants.StationRecommendHierarchyType, + constants.CompNSPathRecommendHierarchyType, + constants.CompTagRecommendHierarchyType, + constants.MeasTagRecommendHierarchyType: + return getSpecificKeyByLength(hierarchy, inputSlice[specificKeyIndex]), true + default: + return "", false + } +} + +// getAllSetKeyByHierarchy 返回某个推荐层级对应的全量 Redis set key。 func getAllSetKeyByHierarchy(hierarchy constants.RecommendHierarchyType) (string, bool) { switch hierarchy { case constants.GridRecommendHierarchyType: @@ -429,6 +493,7 @@ func getAllSetKeyByHierarchy(hierarchy constants.RecommendHierarchyType) (string } } +// getAllKeyByGridLevel 返回所有 grid tag, 用于空输入时的初始推荐。 func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { setKey := constants.RedisAllGridSetKey hierarchy := constants.GridRecommendHierarchyType @@ -453,6 +518,7 @@ func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan } } +// getAllKeyByNSPathLevel 返回所有组件 nspath, 用于空输入时的初始推荐。 func getAllKeyByNSPathLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { queryKey := constants.RedisAllCompNSPathSetKey hierarchy := constants.CompNSPathRecommendHierarchyType @@ -477,6 +543,7 @@ func getAllKeyByNSPathLevel(ctx context.Context, rdb *redis.Client, fanInChan ch } } +// combineQueryResultByInput 把当前层级查询结果和输入前缀拼成完整推荐路径。 func combineQueryResultByInput(hierarchy constants.RecommendHierarchyType, recommendLenType string, inputSlice []string, queryResults []string) []string { prefixs := make([]string, 0, len(inputSlice)) recommandResults := make([]string, 0, len(queryResults)) @@ -521,6 +588,7 @@ func combineQueryResultByInput(hierarchy constants.RecommendHierarchyType, recom return recommandResults } +// getSpecificKeyByLength 根据层级和父级 token 生成对应的 specific set key。 func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefix string) string { switch hierarchy { case constants.GridRecommendHierarchyType: @@ -542,6 +610,7 @@ func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefi } } +// handleNSPathMeasSearch 处理 nspath.measurement 的简写搜索路径。 func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice []string, fanInChan chan SearchResult) { hierarchy := constants.MeasTagRecommendHierarchyType nsPath := inputSlice[0] @@ -632,6 +701,7 @@ func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice [ } } +// combineNSPathMeasResults 把 nspath 与 measurement tag 拼成简写推荐结果。 func combineNSPathMeasResults(nsPath string, measTags []string) []string { results := make([]string, 0, len(measTags)) for _, measTag := range measTags { @@ -640,6 +710,7 @@ func combineNSPathMeasResults(nsPath string, measTags []string) []string { return results } +// runNSPathMeasFuzzySearch 对 nspath.measurement 简写做模糊搜索, 无结果时逐字回退。 func runNSPathMeasFuzzySearch(ctx context.Context, nsPath string, searchInput string) ([]string, int, error) { searchInputLen := len([]rune(searchInput)) for searchInputLen != 0 { @@ -679,6 +750,7 @@ func runNSPathMeasFuzzySearch(ctx context.Context, nsPath string, searchInput st return []string{}, 0, nil } +// splitLastToken 拆分最后一个点号后的 token, 返回前缀和末级 token。 func splitLastToken(term string) (string, string) { lastDotIndex := strings.LastIndex(term, ".") if lastDotIndex == -1 { @@ -687,7 +759,7 @@ func splitLastToken(term string) (string, string) { return term[:lastDotIndex], term[lastDotIndex+1:] } -// handleLevelFuzzySearch define func to process recommendation logic for specific levels(level >= 2) +// handleLevelFuzzySearch 处理第二层及以后层级的精确、模糊和兜底推荐。 func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, redisSetKey string, inputSlice []string, fanInChan chan SearchResult) { inputSliceLen := len(inputSlice) searchInputIndex := inputSliceLen - 1 @@ -820,7 +892,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co isFallback := false if len(recommends) == 0 { logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err) - recommends, err = fallbackAllCurrentLevel(ctx, rdb, hierarchy) + recommends, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, inputSlice) if err != nil { logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) fanInChan <- SearchResult{ @@ -846,7 +918,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } } -// runFuzzySearch define func to process redis fuzzy search +// runFuzzySearch 执行 RediSearch 模糊搜索, 并用 Redis set 校验候选项确属当前层级。 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 @@ -904,14 +976,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, termLastPart = term[lastDotIndex+1:] } - if recommendLenType == constants.FullRecommendLength && hierarchy == constants.GridRecommendHierarchyType { - exists, err := rdb.SIsMember(ctx, constants.RedisAllGridSetKey, termLastPart).Result() - if err != nil || !exists { - logger.Info(ctx, "query key by redis fuzzy search failed or term not in redis all grid set", "query_key", fuzzyInput, "exists", exists, "error", err) - continue - } - } - switch recommendLenType { case constants.FullRecommendLength: if result.Term == "" { @@ -928,6 +992,20 @@ 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, recommendLenType, comparePrefix, termLastPart) + if err != nil || !exists { + logger.Info(ctx, "fuzzy recommend term not found in specific redis set", + "hierarchy", hierarchy, + "recommend_len_type", recommendLenType, + "query_key", fuzzyInput, + "term", term, + "term_last_part", termLastPart, + "exists", exists, + "error", err, + ) + continue + } + recommend := result.Term if hierarchy == constants.MeasTagRecommendHierarchyType { recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".") @@ -942,11 +1020,59 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, continue } - return recommends, len([]rune(searchInput)), nil + return recommends, fuzzyRecommendOffset(searchPrefix, searchInput), nil } return []string{}, 0, nil } +// fuzzyRecommendOffset 返回模糊命中的裁剪位置, 保留完整输入前缀参与 offset。 +func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { + if searchPrefix == "" { + return len([]rune(searchInput)) + } + return len([]rune(searchPrefix)) + 1 + len([]rune(searchInput)) +} + +// fuzzyRecommendMemberExists 校验模糊候选末级 token 是否存在于对应 Redis set。 +func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string, termLastPart string) (bool, error) { + setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, recommendLenType, comparePrefix) + if !ok { + return true, nil + } + return rdb.SIsMember(ctx, setKey, termLastPart).Result() +} + +// fuzzyRecommendMemberSetKey 返回模糊候选校验时应使用的 Redis set key。 +func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string) (string, bool) { + switch hierarchy { + case constants.GridRecommendHierarchyType: + return constants.RedisAllGridSetKey, true + case constants.ZoneRecommendHierarchyType: + return fmt.Sprintf(constants.RedisSpecGridZoneSetKey, comparePrefix), comparePrefix != "" + case constants.StationRecommendHierarchyType: + return fmt.Sprintf(constants.RedisSpecZoneStationSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" + case constants.CompNSPathRecommendHierarchyType: + return fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" + case constants.CompTagRecommendHierarchyType: + return fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" + case constants.ConfigRecommendHierarchyType: + return constants.RedisAllConfigSetKey, true + case constants.MeasTagRecommendHierarchyType: + return fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" + default: + return "", false + } +} + +// lastRecommendPrefixToken 取推荐前缀中的最后一个层级 token。 +func lastRecommendPrefixToken(prefix string) string { + if lastDotIndex := strings.LastIndex(prefix, "."); lastDotIndex != -1 { + return prefix[lastDotIndex+1:] + } + return prefix +} + +// trimLastRune 删除字符串最后一个 rune, 用于模糊搜索逐字回退。 func trimLastRune(s string) string { runes := []rune(s) if len(runes) == 0 { diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go new file mode 100644 index 0000000..259aee0 --- /dev/null +++ b/model/redis_recommend_test.go @@ -0,0 +1,40 @@ +package model + +import "testing" + +func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) { + tests := []struct { + name string + searchPrefix string + searchInput string + want int + }{ + { + name: "zone fuzzy prefix", + searchPrefix: "grid000", + searchInput: "z", + want: len([]rune("grid000.z")), + }, + { + name: "level one fuzzy", + searchPrefix: "", + searchInput: "g", + want: len([]rune("g")), + }, + { + name: "measurement fuzzy preserves config token", + searchPrefix: "grid.zone.station.nspath.comp.config", + searchInput: "m", + want: len([]rune("grid.zone.station.nspath.comp.config.m")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fuzzyRecommendOffset(tt.searchPrefix, tt.searchInput) + if got != tt.want { + t.Fatalf("expected offset %d, got %d", tt.want, got) + } + }) + } +} From f9824e2b24b1f8bf88f85766b17759cf2e3dc3f0 Mon Sep 17 00:00:00 2001 From: douxu Date: Wed, 8 Jul 2026 10:37:06 +0800 Subject: [PATCH 07/14] fix: propagate request context through handlers - pass c.Request.Context() to handler logs, DB calls, Redis wrappers, and subscription operations - avoid passing gin.Context into downstream model/database/diagram layers - keep async Redis backfill trace context without coupling it to request cancellation - add caller skip handling for facade logger output --- handler/alert_event_query.go | 3 +- handler/anchor_point_replace.go | 9 ++-- handler/attr_delete.go | 9 ++-- handler/attr_load.go | 11 +++-- handler/attr_update.go | 9 ++-- handler/circuit_diagram_create.go | 19 ++++---- handler/circuit_diagram_delete.go | 23 +++++----- handler/circuit_diagram_load.go | 17 +++---- handler/circuit_diagram_update.go | 21 +++++---- handler/component_attribute_query.go | 22 +++++---- handler/component_attribute_update.go | 15 +++--- handler/diagram_node_link.go | 15 +++--- handler/history_data_query.go | 5 +- handler/measurement_load.go | 13 +++--- handler/measurement_recommend.go | 11 +++-- handler/mesurement_link.go | 25 +++++----- handler/real_time_data_pull.go | 9 ++-- handler/real_time_data_query.go | 17 +++---- handler/real_time_data_receive.go | 19 ++++---- handler/real_time_data_subscription.go | 23 +++++----- logger/facede.go | 12 +++-- model/redis_recommend_test.go | 63 +++++++++++++++++++++++++- 22 files changed, 229 insertions(+), 141 deletions(-) diff --git a/handler/alert_event_query.go b/handler/alert_event_query.go index b49d954..7f7e107 100644 --- a/handler/alert_event_query.go +++ b/handler/alert_event_query.go @@ -15,13 +15,14 @@ import ( // QueryAlertEventHandler define query alert event process API func QueryAlertEventHandler(c *gin.Context) { + ctx := c.Request.Context() var targetLevel constants.AlertLevel alertManger := alert.GetAlertMangerInstance() levelStr := c.Query("level") level, err := strconv.Atoi(levelStr) if err != nil { - logger.Error(c, "convert alert level string to int failed", "error", err) + logger.Error(ctx, "convert alert level string to int failed", "error", err) resp := network.FailureResponse{ Code: -1, diff --git a/handler/anchor_point_replace.go b/handler/anchor_point_replace.go index 9bdf0ab..3501f91 100644 --- a/handler/anchor_point_replace.go +++ b/handler/anchor_point_replace.go @@ -19,15 +19,16 @@ import ( // ComponentAnchorReplaceHandler define component anchor point replace process API func ComponentAnchorReplaceHandler(c *gin.Context) { + ctx := c.Request.Context() var uuid, anchorName string pgClient := database.GetPostgresDBClient() - cancelCtx, cancel := context.WithTimeout(c, 5*time.Second) + cancelCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var request network.ComponetAnchorReplaceRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "unmarshal component anchor point replace info failed", "error", err) + logger.Error(ctx, "unmarshal component anchor point replace info failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -42,7 +43,7 @@ func ComponentAnchorReplaceHandler(c *gin.Context) { var componentInfo orm.Component result := pgClient.WithContext(cancelCtx).Model(&orm.Component{}).Where("global_uuid = ?", uuid).Find(&componentInfo) if result.Error != nil { - logger.Error(c, "query component detail info failed", "error", result.Error) + logger.Error(ctx, "query component detail info failed", "error", result.Error) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -54,7 +55,7 @@ func ComponentAnchorReplaceHandler(c *gin.Context) { if result.RowsAffected == 0 { err := fmt.Errorf("query component detail info by uuid failed:%w", errcode.ErrQueryRowZero) - logger.Error(c, "query component detail info from table is empty", "table_name", "component") + logger.Error(ctx, "query component detail info from table is empty", "table_name", "component") resp := network.FailureResponse{ Code: http.StatusBadRequest, diff --git a/handler/attr_delete.go b/handler/attr_delete.go index 783c01f..c0e4429 100644 --- a/handler/attr_delete.go +++ b/handler/attr_delete.go @@ -13,12 +13,13 @@ import ( // AttrDeleteHandler deletes a data attribute func AttrDeleteHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.AttrDeleteRequest clientToken := c.GetString("client_token") if clientToken == "" { err := common.ErrGetClientToken - logger.Error(c, "failed to get client token from context", "error", err) + logger.Error(ctx, "failed to get client token from context", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -27,7 +28,7 @@ func AttrDeleteHandler(c *gin.Context) { } if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal attribute delete request", "error", err) + logger.Error(ctx, "failed to unmarshal attribute delete request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -35,9 +36,9 @@ func AttrDeleteHandler(c *gin.Context) { return } - rs := diagram.NewRedisString(c, request.AttrToken, clientToken, 10, true) + rs := diagram.NewRedisString(ctx, request.AttrToken, clientToken, 10, true) if err := rs.GETDEL(request.AttrToken); err != nil { - logger.Error(c, "failed to delete attribute from Redis", "attr_token", request.AttrToken, "error", err) + logger.Error(ctx, "failed to delete attribute from Redis", "attr_token", request.AttrToken, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), diff --git a/handler/attr_load.go b/handler/attr_load.go index 3591910..69a9f2a 100644 --- a/handler/attr_load.go +++ b/handler/attr_load.go @@ -13,13 +13,14 @@ import ( // AttrGetHandler retrieves the value of a data attribute func AttrGetHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.AttrGetRequest clientToken := c.GetString("client_token") if clientToken == "" { err := common.ErrGetClientToken - logger.Error(c, "failed to get client token from context", "error", err) + logger.Error(ctx, "failed to get client token from context", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -28,7 +29,7 @@ func AttrGetHandler(c *gin.Context) { } if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal attribute get request", "error", err) + logger.Error(ctx, "failed to unmarshal attribute get request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -37,12 +38,12 @@ func AttrGetHandler(c *gin.Context) { } pgClient := database.GetPostgresDBClient() - tx := pgClient.Begin() + tx := pgClient.WithContext(ctx).Begin() - attrModel, err := database.ParseAttrToken(c, tx, request.AttrToken, clientToken) + attrModel, err := database.ParseAttrToken(ctx, tx, request.AttrToken, clientToken) if err != nil { tx.Rollback() - logger.Error(c, "failed to parse attribute token", "attr_token", request.AttrToken, "error", err) + logger.Error(ctx, "failed to parse attribute token", "attr_token", request.AttrToken, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), diff --git a/handler/attr_update.go b/handler/attr_update.go index 226631f..885611c 100644 --- a/handler/attr_update.go +++ b/handler/attr_update.go @@ -13,13 +13,14 @@ import ( // AttrSetHandler sets the value of a data attribute func AttrSetHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.AttrSetRequest clientToken := c.GetString("client_token") if clientToken == "" { err := common.ErrGetClientToken - logger.Error(c, "failed to get client token from context", "error", err) + logger.Error(ctx, "failed to get client token from context", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -28,7 +29,7 @@ func AttrSetHandler(c *gin.Context) { } if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal attribute set request", "error", err) + logger.Error(ctx, "failed to unmarshal attribute set request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -37,9 +38,9 @@ func AttrSetHandler(c *gin.Context) { } // The logic for handling Redis operations directly from the handler - rs := diagram.NewRedisString(c, request.AttrToken, clientToken, 10, true) + rs := diagram.NewRedisString(ctx, request.AttrToken, clientToken, 10, true) if err := rs.Set(request.AttrToken, request.AttrValue); err != nil { - logger.Error(c, "failed to set attribute value in Redis", "attr_token", request.AttrToken, "error", err) + logger.Error(ctx, "failed to set attribute value in Redis", "attr_token", request.AttrToken, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), diff --git a/handler/circuit_diagram_create.go b/handler/circuit_diagram_create.go index 854e0c8..784a0d2 100644 --- a/handler/circuit_diagram_create.go +++ b/handler/circuit_diagram_create.go @@ -16,11 +16,12 @@ import ( // CircuitDiagramCreateHandler define circuit diagram create process API func CircuitDiagramCreateHandler(c *gin.Context) { + ctx := c.Request.Context() pgClient := database.GetPostgresDBClient() var request network.CircuitDiagramCreateRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "unmarshal circuit diagram create info failed", "error", err) + logger.Error(ctx, "unmarshal circuit diagram create info failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -32,7 +33,7 @@ func CircuitDiagramCreateHandler(c *gin.Context) { graph, err := diagram.GetGraphMap(request.PageID) if err != nil { - logger.Error(c, "get topologic data from set by pageID failed", "error", err) + logger.Error(ctx, "get topologic data from set by pageID failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -60,7 +61,7 @@ func CircuitDiagramCreateHandler(c *gin.Context) { err = fmt.Errorf("convert uuid from string failed:%w:%w", err1, err2) } - logger.Error(c, "format uuid from string failed", "error", err) + logger.Error(ctx, "format uuid from string failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -78,13 +79,13 @@ func CircuitDiagramCreateHandler(c *gin.Context) { } // open transaction - tx := pgClient.Begin() + tx := pgClient.WithContext(ctx).Begin() - err = database.CreateTopologicIntoDB(c, tx, request.PageID, topologicCreateInfos) + err = database.CreateTopologicIntoDB(ctx, tx, request.PageID, topologicCreateInfos) if err != nil { tx.Rollback() - logger.Error(c, "create topologic info into DB failed", "topologic_info", topologicCreateInfos, "error", err) + logger.Error(ctx, "create topologic info into DB failed", "topologic_info", topologicCreateInfos, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -102,11 +103,11 @@ func CircuitDiagramCreateHandler(c *gin.Context) { } for index, info := range request.ComponentInfos { - componentUUID, err := database.CreateComponentIntoDB(c, tx, info) + componentUUID, err := database.CreateComponentIntoDB(ctx, tx, info) if err != nil { tx.Rollback() - logger.Error(c, "insert component info into DB failed", "error", err) + logger.Error(ctx, "insert component info into DB failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -125,7 +126,7 @@ func CircuitDiagramCreateHandler(c *gin.Context) { // TODO 修复赋值问题 component, err := network.ConvertComponentCreateInfosToComponents(info) if err != nil { - logger.Error(c, "convert component params info failed", "component_info", info, "error", err) + logger.Error(ctx, "convert component params info failed", "component_info", info, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, diff --git a/handler/circuit_diagram_delete.go b/handler/circuit_diagram_delete.go index 2025ad0..a358c91 100644 --- a/handler/circuit_diagram_delete.go +++ b/handler/circuit_diagram_delete.go @@ -21,11 +21,12 @@ import ( // CircuitDiagramDeleteHandler define circuit diagram delete process API func CircuitDiagramDeleteHandler(c *gin.Context) { + ctx := c.Request.Context() pgClient := database.GetPostgresDBClient() var request network.CircuitDiagramDeleteRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "unmarshal circuit diagram del info failed", "error", err) + logger.Error(ctx, "unmarshal circuit diagram del info failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -37,7 +38,7 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { graph, err := diagram.GetGraphMap(request.PageID) if err != nil { - logger.Error(c, "get topologic data from set by pageID failed", "error", err) + logger.Error(ctx, "get topologic data from set by pageID failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -65,7 +66,7 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { err = fmt.Errorf("convert uuid from string failed:%w:%w", err1, err2) } - logger.Error(c, "format uuid from string failed", "error", err) + logger.Error(ctx, "format uuid from string failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -83,14 +84,14 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { } // open transaction - tx := pgClient.Begin() + tx := pgClient.WithContext(ctx).Begin() for _, topologicDelInfo := range topologicDelInfos { - err = database.DeleteTopologicIntoDB(c, tx, request.PageID, topologicDelInfo) + err = database.DeleteTopologicIntoDB(ctx, tx, request.PageID, topologicDelInfo) if err != nil { tx.Rollback() - logger.Error(c, "delete topologic info into DB failed", "topologic_info", topologicDelInfo, "error", err) + logger.Error(ctx, "delete topologic info into DB failed", "topologic_info", topologicDelInfo, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -107,7 +108,7 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { if err != nil { tx.Rollback() - logger.Error(c, "delete topologic info failed", "topologic_info", topologicDelInfo, "error", err) + logger.Error(ctx, "delete topologic info failed", "topologic_info", topologicDelInfo, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -126,14 +127,14 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { } for _, componentInfo := range request.ComponentInfos { - cancelCtx, cancel := context.WithTimeout(c, 5*time.Second) + cancelCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() globalUUID, err := uuid.FromString(componentInfo.UUID) if err != nil { tx.Rollback() - logger.Error(c, "format uuid from string failed", "error", err) + logger.Error(ctx, "format uuid from string failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -157,7 +158,7 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { err = fmt.Errorf("%w:please check uuid conditions", errcode.ErrDeleteRowZero) } - logger.Error(c, "query component info into postgresDB failed", "component_global_uuid", componentInfo.UUID, "error", err) + logger.Error(ctx, "query component info into postgresDB failed", "component_global_uuid", componentInfo.UUID, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -179,7 +180,7 @@ func CircuitDiagramDeleteHandler(c *gin.Context) { err = fmt.Errorf("%w:please check uuid conditions", errcode.ErrDeleteRowZero) } - logger.Error(c, "delete component info into postgresDB failed", "component_global_uuid", componentInfo.UUID, "error", err) + logger.Error(ctx, "delete component info into postgresDB failed", "component_global_uuid", componentInfo.UUID, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, diff --git a/handler/circuit_diagram_load.go b/handler/circuit_diagram_load.go index 7ad390b..5608321 100644 --- a/handler/circuit_diagram_load.go +++ b/handler/circuit_diagram_load.go @@ -24,11 +24,12 @@ import ( // @Failure 400 {object} network.FailureResponse "request process failed" // @Router /model/diagram_load/{page_id} [get] func CircuitDiagramLoadHandler(c *gin.Context) { + ctx := c.Request.Context() pgClient := database.GetPostgresDBClient() pageID, err := strconv.ParseInt(c.Query("page_id"), 10, 64) if err != nil { - logger.Error(c, "get pageID from url param failed", "error", err) + logger.Error(ctx, "get pageID from url param failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -43,7 +44,7 @@ func CircuitDiagramLoadHandler(c *gin.Context) { topologicInfo, err := diagram.GetGraphMap(pageID) if err != nil { - logger.Error(c, "get topologic data from set by pageID failed", "error", err) + logger.Error(ctx, "get topologic data from set by pageID failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -62,9 +63,9 @@ func CircuitDiagramLoadHandler(c *gin.Context) { componentParamMap := make(map[string]any) for _, VerticeLink := range topologicInfo.VerticeLinks { for _, componentUUID := range VerticeLink { - component, err := database.QueryComponentByUUID(c, pgClient, componentUUID) + component, err := database.QueryComponentByUUID(ctx, pgClient, componentUUID) if err != nil { - logger.Error(c, "get component id info from DB by uuid failed", "error", err) + logger.Error(ctx, "get component id info from DB by uuid failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -79,7 +80,7 @@ func CircuitDiagramLoadHandler(c *gin.Context) { componentParams, err := diagram.GetComponentMap(component.GlobalUUID.String()) if err != nil { - logger.Error(c, "get component data from set by uuid failed", "error", err) + logger.Error(ctx, "get component data from set by uuid failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -96,9 +97,9 @@ func CircuitDiagramLoadHandler(c *gin.Context) { } rootVertexUUID := topologicInfo.RootVertex.String() - rootComponent, err := database.QueryComponentByUUID(c, pgClient, topologicInfo.RootVertex) + rootComponent, err := database.QueryComponentByUUID(ctx, pgClient, topologicInfo.RootVertex) if err != nil { - logger.Error(c, "get component id info from DB by uuid failed", "error", err) + logger.Error(ctx, "get component id info from DB by uuid failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -113,7 +114,7 @@ func CircuitDiagramLoadHandler(c *gin.Context) { rootComponentParam, err := diagram.GetComponentMap(rootComponent.GlobalUUID.String()) if err != nil { - logger.Error(c, "get component data from set by uuid failed", "error", err) + logger.Error(ctx, "get component data from set by uuid failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, diff --git a/handler/circuit_diagram_update.go b/handler/circuit_diagram_update.go index e28a413..93b34a8 100644 --- a/handler/circuit_diagram_update.go +++ b/handler/circuit_diagram_update.go @@ -14,11 +14,12 @@ import ( // CircuitDiagramUpdateHandler define circuit diagram update process API func CircuitDiagramUpdateHandler(c *gin.Context) { + ctx := c.Request.Context() pgClient := database.GetPostgresDBClient() var request network.CircuitDiagramUpdateRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "unmarshal circuit diagram update info failed", "error", err) + logger.Error(ctx, "unmarshal circuit diagram update info failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -30,7 +31,7 @@ func CircuitDiagramUpdateHandler(c *gin.Context) { graph, err := diagram.GetGraphMap(request.PageID) if err != nil { - logger.Error(c, "get topologic data from set by pageID failed", "error", err) + logger.Error(ctx, "get topologic data from set by pageID failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -47,7 +48,7 @@ func CircuitDiagramUpdateHandler(c *gin.Context) { for _, topologicLink := range request.TopologicLinks { changeInfo, err := network.ParseUUID(topologicLink) if err != nil { - logger.Error(c, "format uuid from string failed", "error", err) + logger.Error(ctx, "format uuid from string failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -63,14 +64,14 @@ func CircuitDiagramUpdateHandler(c *gin.Context) { } // open transaction - tx := pgClient.Begin() + tx := pgClient.WithContext(ctx).Begin() for _, topologicChangeInfo := range topologicChangeInfos { - err = database.UpdateTopologicIntoDB(c, tx, request.PageID, topologicChangeInfo) + err = database.UpdateTopologicIntoDB(ctx, tx, request.PageID, topologicChangeInfo) if err != nil { tx.Rollback() - logger.Error(c, "update topologic info into DB failed", "topologic_info", topologicChangeInfo, "error", err) + logger.Error(ctx, "update topologic info into DB failed", "topologic_info", topologicChangeInfo, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -87,7 +88,7 @@ func CircuitDiagramUpdateHandler(c *gin.Context) { if err != nil { tx.Rollback() - logger.Error(c, "update topologic info failed", "topologic_info", topologicChangeInfo, "error", err) + logger.Error(ctx, "update topologic info failed", "topologic_info", topologicChangeInfo, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -102,9 +103,9 @@ func CircuitDiagramUpdateHandler(c *gin.Context) { } for index, componentInfo := range request.ComponentInfos { - componentUUID, err := database.UpdateComponentIntoDB(c, tx, componentInfo) + componentUUID, err := database.UpdateComponentIntoDB(ctx, tx, componentInfo) if err != nil { - logger.Error(c, "udpate component info into DB failed", "error", err) + logger.Error(ctx, "udpate component info into DB failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -124,7 +125,7 @@ func CircuitDiagramUpdateHandler(c *gin.Context) { // TODO 修复赋值问题 component, err := network.ConvertComponentUpdateInfosToComponents(info) if err != nil { - logger.Error(c, "convert component params info failed", "component_info", info, "error", err) + logger.Error(ctx, "convert component params info failed", "component_info", info, "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, diff --git a/handler/component_attribute_query.go b/handler/component_attribute_query.go index 44e9462..bfa24d8 100644 --- a/handler/component_attribute_query.go +++ b/handler/component_attribute_query.go @@ -22,12 +22,13 @@ import ( // ComponentAttributeQueryHandler define circuit diagram component attribute value query process API func ComponentAttributeQueryHandler(c *gin.Context) { + ctx := c.Request.Context() pgClient := database.GetPostgresDBClient() tokens := c.Param("tokens") if tokens == "" { err := fmt.Errorf("tokens is missing from the path") - logger.Error(c, "query tokens from path failed", "error", err, "url", c.Request.RequestURI) + logger.Error(ctx, "query tokens from path failed", "error", err, "url", c.Request.RequestURI) renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) return } @@ -54,10 +55,10 @@ func ComponentAttributeQueryHandler(c *gin.Context) { dbQueryMap := make(map[string][]cacheQueryItem) var secondaryQueryCount int for hSetKey, items := range cacheQueryMap { - hset := diagram.NewRedisHash(c, hSetKey, 5000, false) + hset := diagram.NewRedisHash(ctx, hSetKey, 5000, false) cacheData, err := hset.HGetAll() if err != nil { - logger.Warn(c, "redis hgetall failed", "key", hSetKey, "err", err) + logger.Warn(ctx, "redis hgetall failed", "key", hSetKey, "err", err) } for _, item := range items { if val, ok := cacheData[item.attributeName]; ok { @@ -75,9 +76,9 @@ func ComponentAttributeQueryHandler(c *gin.Context) { return } - tx := pgClient.WithContext(c).Begin() + tx := pgClient.WithContext(ctx).Begin() if tx.Error != nil { - logger.Error(c, "begin postgres transaction failed", "error", tx.Error) + logger.Error(ctx, "begin postgres transaction failed", "error", tx.Error) fillRemainingErrors(queryResults, tokenSlice, errcode.ErrBeginTxFailed) payload := genQueryRespPayload(queryResults, tokenSlice) renderRespFailure(c, constants.RespCodeServerError, "begin postgres database transaction failed", payload) @@ -86,9 +87,9 @@ func ComponentAttributeQueryHandler(c *gin.Context) { defer tx.Rollback() allCompTags := slices.Collect(maps.Keys(dbQueryMap)) - compModelMap, err := database.QueryComponentByCompTags(c, tx, allCompTags) + compModelMap, err := database.QueryComponentByCompTags(ctx, tx, allCompTags) if err != nil { - logger.Error(c, "query component info from postgres database failed", "error", err) + logger.Error(ctx, "query component info from postgres database failed", "error", err) fillRemainingErrors(queryResults, tokenSlice, errcode.ErrDBQueryFailed) payload := genQueryRespPayload(queryResults, tokenSlice) renderRespFailure(c, constants.RespCodeServerError, "query component meta failed", payload) @@ -116,7 +117,7 @@ func ComponentAttributeQueryHandler(c *gin.Context) { tableNameMap, err := database.BatchGetProjectNames(tx, identifiers) if err != nil { - logger.Error(c, "batch get table names from postgres database failed", "error", err) + logger.Error(ctx, "batch get table names from postgres database failed", "error", err) fillRemainingErrors(queryResults, tokenSlice, errcode.ErrRetrieveFailed) payload := genQueryRespPayload(queryResults, tokenSlice) renderRespFailure(c, constants.RespCodeServerError, "batch get table names from postgres database failed", payload) @@ -151,10 +152,11 @@ func ComponentAttributeQueryHandler(c *gin.Context) { } if err := tx.Commit().Error; err != nil { - logger.Warn(c, "postgres transaction commit failed, but returning scanned data", "error", err) + logger.Warn(ctx, "postgres transaction commit failed, but returning scanned data", "error", err) } else { + backfillCtx := context.WithoutCancel(ctx) for hKey, items := range redisSyncMap { - go backfillRedis(c.Copy(), hKey, items) + go backfillRedis(backfillCtx, hKey, items) } } diff --git a/handler/component_attribute_update.go b/handler/component_attribute_update.go index 778fb42..8a10f67 100644 --- a/handler/component_attribute_update.go +++ b/handler/component_attribute_update.go @@ -18,10 +18,11 @@ import ( // ComponentAttributeUpdateHandler define circuit diagram component attribute value update process API func ComponentAttributeUpdateHandler(c *gin.Context) { + ctx := c.Request.Context() pgClient := database.GetPostgresDBClient() var request network.ComponentAttributeUpdateInfo if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "unmarshal request params failed", "error", err) + logger.Error(ctx, "unmarshal request params failed", "error", err) renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) return } @@ -54,16 +55,16 @@ func ComponentAttributeUpdateHandler(c *gin.Context) { } // open transaction - tx := pgClient.WithContext(c).Begin() + tx := pgClient.WithContext(ctx).Begin() if tx.Error != nil { - logger.Error(c, "begin postgres transaction failed", "error", tx.Error) + logger.Error(ctx, "begin postgres transaction failed", "error", tx.Error) renderRespFailure(c, constants.RespCodeServerError, "begin postgres transaction failed", nil) return } - compInfo, err := database.QueryComponentByCompTag(c, tx, attributeComponentTag) + compInfo, err := database.QueryComponentByCompTag(ctx, tx, attributeComponentTag) if err != nil { - logger.Error(c, "query component info by component tag failed", "error", err, "tag", attributeComponentTag) + logger.Error(ctx, "query component info by component tag failed", "error", err, "tag", attributeComponentTag) for _, attribute := range request.AttributeConfigs { if _, exists := updateResults[attribute.AttributeToken]; !exists { @@ -139,7 +140,7 @@ func ComponentAttributeUpdateHandler(c *gin.Context) { } for key, items := range redisUpdateMap { - hset := diagram.NewRedisHash(c, key, 5000, false) + hset := diagram.NewRedisHash(ctx, key, 5000, false) fields := make(map[string]any, len(items)) for _, item := range items { @@ -147,7 +148,7 @@ func ComponentAttributeUpdateHandler(c *gin.Context) { } if err := hset.SetRedisHashByMap(fields); err != nil { - logger.Error(c, "batch sync redis failed", "hash_key", key, "error", err) + logger.Error(ctx, "batch sync redis failed", "hash_key", key, "error", err) for _, item := range items { if _, exists := updateResults[item.token]; exists { diff --git a/handler/diagram_node_link.go b/handler/diagram_node_link.go index 8f484a5..ffd09a3 100644 --- a/handler/diagram_node_link.go +++ b/handler/diagram_node_link.go @@ -41,11 +41,12 @@ var linkSetConfigs = map[int]linkSetConfig{ // DiagramNodeLinkHandler defines the diagram node link process api func DiagramNodeLinkHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.DiagramNodeLinkRequest clientToken := c.GetString("client_token") if clientToken == "" { err := common.ErrGetClientToken - logger.Error(c, "failed to get client token from context", "error", err) + logger.Error(ctx, "failed to get client token from context", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -54,7 +55,7 @@ func DiagramNodeLinkHandler(c *gin.Context) { } if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal diagram node process request", "error", err) + logger.Error(ctx, "failed to unmarshal diagram node process request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, @@ -68,9 +69,9 @@ func DiagramNodeLinkHandler(c *gin.Context) { nodeID := request.NodeID nodeLevel := request.NodeLevel action := request.Action - prevNodeInfo, currNodeInfo, err := database.QueryNodeInfoByID(c, pgClient, nodeID, nodeLevel) + prevNodeInfo, currNodeInfo, err := database.QueryNodeInfoByID(ctx, pgClient, nodeID, nodeLevel) if err != nil { - logger.Error(c, "failed to query diagram node info by nodeID and level from postgres", "node_id", nodeID, "level", nodeLevel, "error", err) + logger.Error(ctx, "failed to query diagram node info by nodeID and level from postgres", "node_id", nodeID, "level", nodeLevel, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, @@ -84,8 +85,8 @@ func DiagramNodeLinkHandler(c *gin.Context) { return } - prevLinkSet, currLinkSet := generateLinkSet(c, nodeLevel, prevNodeInfo) - err = processLinkSetData(c, action, nodeLevel, prevLinkSet, currLinkSet, prevNodeInfo, currNodeInfo) + prevLinkSet, currLinkSet := generateLinkSet(ctx, nodeLevel, prevNodeInfo) + err = processLinkSetData(ctx, action, nodeLevel, prevLinkSet, currLinkSet, prevNodeInfo, currNodeInfo) if err != nil { c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, @@ -99,7 +100,7 @@ func DiagramNodeLinkHandler(c *gin.Context) { return } - logger.Info(c, "process diagram node link success", "node_id", nodeID, "level", nodeLevel, "action", request.Action) + logger.Info(ctx, "process diagram node link success", "node_id", nodeID, "level", nodeLevel, "action", request.Action) c.JSON(http.StatusOK, network.SuccessResponse{ Code: http.StatusOK, diff --git a/handler/history_data_query.go b/handler/history_data_query.go index f9df2a2..965debd 100644 --- a/handler/history_data_query.go +++ b/handler/history_data_query.go @@ -16,11 +16,12 @@ import ( // QueryHistoryDataHandler define query history data process API func QueryHistoryDataHandler(c *gin.Context) { + ctx := c.Request.Context() token := c.Query("token") beginStr := c.Query("begin") begin, err := strconv.Atoi(beginStr) if err != nil { - logger.Error(c, "convert begin param from string to int failed", "error", err) + logger.Error(ctx, "convert begin param from string to int failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, @@ -31,7 +32,7 @@ func QueryHistoryDataHandler(c *gin.Context) { endStr := c.Query("end") end, err := strconv.Atoi(endStr) if err != nil { - logger.Error(c, "convert end param from string to int failed", "error", err) + logger.Error(ctx, "convert end param from string to int failed", "error", err) resp := network.FailureResponse{ Code: http.StatusBadRequest, diff --git a/handler/measurement_load.go b/handler/measurement_load.go index 26dbbed..ddae642 100644 --- a/handler/measurement_load.go +++ b/handler/measurement_load.go @@ -15,13 +15,14 @@ import ( // MeasurementGetHandler define measurement query API func MeasurementGetHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.MeasurementGetRequest clientToken := c.GetString("client_token") if clientToken == "" { err := common.ErrGetClientToken - logger.Error(c, "failed to get client token from context", "error", err) + logger.Error(ctx, "failed to get client token from context", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -30,7 +31,7 @@ func MeasurementGetHandler(c *gin.Context) { } if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal measurement get request", "error", err) + logger.Error(ctx, "failed to unmarshal measurement get request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -38,10 +39,10 @@ func MeasurementGetHandler(c *gin.Context) { return } - zset := diagram.NewRedisZSet(c, request.MeasurementToken, 0, false) + zset := diagram.NewRedisZSet(ctx, request.MeasurementToken, 0, false) points, err := zset.ZRANGE(request.MeasurementToken, 0, -1) if err != nil { - logger.Error(c, "failed to get measurement data from redis", "measurement_token", request.MeasurementToken, "error", err) + logger.Error(ctx, "failed to get measurement data from redis", "measurement_token", request.MeasurementToken, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusInternalServerError, Msg: err.Error(), @@ -54,9 +55,9 @@ func MeasurementGetHandler(c *gin.Context) { } pgClient := database.GetPostgresDBClient() - measurementInfo, err := database.QueryMeasurementByID(c, pgClient, request.MeasurementID) + measurementInfo, err := database.QueryMeasurementByID(ctx, pgClient, request.MeasurementID) if err != nil { - logger.Error(c, "failed to query measurement by id", "measurement_id", request.MeasurementID, "error", err) + logger.Error(ctx, "failed to query measurement by id", "measurement_id", request.MeasurementID, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), diff --git a/handler/measurement_recommend.go b/handler/measurement_recommend.go index b3d0ab6..da3972e 100644 --- a/handler/measurement_recommend.go +++ b/handler/measurement_recommend.go @@ -2,11 +2,12 @@ package handler import ( + "net/http" + "modelRT/constants" "modelRT/logger" "modelRT/model" "modelRT/network" - "net/http" "github.com/gin-gonic/gin" ) @@ -43,18 +44,18 @@ import ( // // @Router /measurement/recommend [get] func MeasurementRecommendHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.MeasurementRecommendRequest if err := c.ShouldBindQuery(&request); err != nil { - logger.Error(c, "failed to bind measurement recommend request", "error", err) + logger.Error(ctx, "failed to bind measurement recommend request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), }) return } - - recommendResults := model.RedisSearchRecommend(c, request.Input) + recommendResults := model.RedisSearchRecommend(ctx, request.Input) payload := network.MeasurementRecommendPayload{ Input: request.Input, RecommendedList: make([]string, 0), @@ -65,7 +66,7 @@ func MeasurementRecommendHandler(c *gin.Context) { for _, recommendResult := range orderedResults { if recommendResult.Err != nil { err := recommendResult.Err - logger.Error(c, "failed to get recommend data from redis", "input", request.Input, "error", 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(), diff --git a/handler/mesurement_link.go b/handler/mesurement_link.go index 1a878f6..8737840 100644 --- a/handler/mesurement_link.go +++ b/handler/mesurement_link.go @@ -18,11 +18,12 @@ import ( // MeasurementLinkHandler defines the measurement link process api func MeasurementLinkHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.MeasurementLinkRequest clientToken := c.GetString("client_token") if clientToken == "" { err := common.ErrGetClientToken - logger.Error(c, "failed to get client token from context", "error", err) + logger.Error(ctx, "failed to get client token from context", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -31,7 +32,7 @@ func MeasurementLinkHandler(c *gin.Context) { } if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal measurement process request", "error", err) + logger.Error(ctx, "failed to unmarshal measurement process request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, @@ -44,9 +45,9 @@ func MeasurementLinkHandler(c *gin.Context) { pgClient := database.GetPostgresDBClient() measurementID := request.MeasurementID action := request.Action - measurementInfo, err := database.QueryMeasurementByID(c, pgClient, measurementID) + measurementInfo, err := database.QueryMeasurementByID(ctx, pgClient, measurementID) if err != nil { - logger.Error(c, "failed to query measurement info by measurement id from postgres", "meauserement_id", measurementID, "error", err) + logger.Error(ctx, "failed to query measurement info by measurement id from postgres", "meauserement_id", measurementID, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, @@ -59,9 +60,9 @@ func MeasurementLinkHandler(c *gin.Context) { return } - componentInfo, err := database.QueryComponentByUUID(c, pgClient, measurementInfo.ComponentUUID) + componentInfo, err := database.QueryComponentByUUID(ctx, pgClient, measurementInfo.ComponentUUID) if err != nil { - logger.Error(c, "failed to query component info by component uuid from postgres", "component_uuid", measurementInfo.ComponentUUID, "error", err) + logger.Error(ctx, "failed to query component info by component uuid from postgres", "component_uuid", measurementInfo.ComponentUUID, "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, @@ -74,9 +75,9 @@ func MeasurementLinkHandler(c *gin.Context) { return } - allMeasSet := diagram.NewRedisSet(c, constants.RedisAllMeasTagSetKey, 0, false) + allMeasSet := diagram.NewRedisSet(ctx, constants.RedisAllMeasTagSetKey, 0, false) compMeasLinkKey := fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, componentInfo.Tag) - compMeasLinkSet := diagram.NewRedisSet(c, compMeasLinkKey, 0, false) + compMeasLinkSet := diagram.NewRedisSet(ctx, compMeasLinkKey, 0, false) switch action { case constants.SearchLinkAddAction: @@ -84,18 +85,18 @@ func MeasurementLinkHandler(c *gin.Context) { err2 := compMeasLinkSet.SADD(measurementInfo.Tag) err = processActionError(err1, err2, action) if err != nil { - logger.Error(c, "add measurement link process operation failed", "measurement_id", measurementID, "action", action, "error", err) + logger.Error(ctx, "add measurement link process operation failed", "measurement_id", measurementID, "action", action, "error", err) } case constants.SearchLinkDelAction: err1 := allMeasSet.SREM(measurementInfo.Tag) err2 := compMeasLinkSet.SREM(measurementInfo.Tag) err = processActionError(err1, err2, action) if err != nil { - logger.Error(c, "del measurement link process operation failed", "measurement_id", measurementID, "action", action, "error", err) + logger.Error(ctx, "del measurement link process operation failed", "measurement_id", measurementID, "action", action, "error", err) } default: err = common.ErrUnsupportedLinkAction - logger.Error(c, "unsupport measurement link process action", "measurement_id", measurementID, "action", action, "error", err) + logger.Error(ctx, "unsupport measurement link process action", "measurement_id", measurementID, "action", action, "error", err) } if err != nil { @@ -110,7 +111,7 @@ func MeasurementLinkHandler(c *gin.Context) { return } - logger.Info(c, "process measurement link success", "measurement_id", measurementID, "action", request.Action) + logger.Info(ctx, "process measurement link success", "measurement_id", measurementID, "action", request.Action) c.JSON(http.StatusOK, network.SuccessResponse{ Code: http.StatusOK, diff --git a/handler/real_time_data_pull.go b/handler/real_time_data_pull.go index 986ed11..a007406 100644 --- a/handler/real_time_data_pull.go +++ b/handler/real_time_data_pull.go @@ -35,27 +35,28 @@ var pullUpgrader = websocket.Upgrader{ // @Tags RealTime Component Websocket // @Router /monitors/data/realtime/stream/:clientID [get] func PullRealTimeDataHandler(c *gin.Context) { + requestCtx := c.Request.Context() clientID := c.Param("clientID") if clientID == "" { err := fmt.Errorf("clientID is missing from the path") - logger.Error(c, "query clientID from path failed", "error", err, "url", c.Request.RequestURI) + logger.Error(requestCtx, "query clientID from path failed", "error", err, "url", c.Request.RequestURI) renderWSRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) return } conn, err := pullUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - logger.Error(c, "upgrade http protocol to websocket protocol failed", "error", err) + logger.Error(requestCtx, "upgrade http protocol to websocket protocol failed", "error", err) renderWSRespFailure(c, constants.RespCodeServerError, err.Error(), nil) return } defer conn.Close() - ctx, cancel := context.WithCancel(c.Request.Context()) + ctx, cancel := context.WithCancel(requestCtx) defer cancel() conn.SetCloseHandler(func(code int, text string) error { - logger.Info(c.Request.Context(), "websocket processor shutdown trigger", + logger.Info(requestCtx, "websocket processor shutdown trigger", "clientID", clientID, "code", code, "reason", text) // call cancel to notify other goroutines to stop working diff --git a/handler/real_time_data_query.go b/handler/real_time_data_query.go index 03e9786..3340588 100644 --- a/handler/real_time_data_query.go +++ b/handler/real_time_data_query.go @@ -60,10 +60,11 @@ var wsUpgrader = websocket.Upgrader{ // // @Router /data/realtime [get] func QueryRealTimeDataHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.RealTimeQueryRequest if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal real time query request", "error", err) + logger.Error(ctx, "failed to unmarshal real time query request", "error", err) c.JSON(http.StatusOK, network.FailureResponse{ Code: http.StatusBadRequest, Msg: err.Error(), @@ -73,7 +74,7 @@ func QueryRealTimeDataHandler(c *gin.Context) { conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - logger.Error(c, "upgrade http protocol to websocket protocal failed", "error", err) + logger.Error(ctx, "upgrade http protocol to websocket protocal failed", "error", err) return } defer conn.Close() @@ -87,29 +88,29 @@ func QueryRealTimeDataHandler(c *gin.Context) { case data := <-transportChannel: respByte, err := jsoniter.Marshal(data) if err != nil { - logger.Error(c, "marshal real time data to bytes failed", "error", err) + logger.Error(ctx, "marshal real time data to bytes failed", "error", err) continue } err = conn.WriteMessage(websocket.TextMessage, respByte) if err != nil { - logger.Error(c, "write message to websocket connection failed", "error", err) + logger.Error(ctx, "write message to websocket connection failed", "error", err) continue } case <-closeChannel: - logger.Info(c, "data receiving goroutine has been closed") + logger.Info(ctx, "data receiving goroutine has been closed") // TODO 优化时间控制 deadline := time.Now().Add(5 * time.Second) err := conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "the session ended normally"), deadline) if err != nil { - logger.Error(c, "sending close control message failed", "error", err) + logger.Error(ctx, "sending close control message failed", "error", err) } // gracefully close session processing err = conn.Close() if err != nil { - logger.Error(c, "websocket conn closed failed", "error", err) + logger.Error(ctx, "websocket conn closed failed", "error", err) } - logger.Info(c, "websocket connection closed successfully.") + logger.Info(ctx, "websocket connection closed successfully.") } } } diff --git a/handler/real_time_data_receive.go b/handler/real_time_data_receive.go index de09a8a..d9550e9 100644 --- a/handler/real_time_data_receive.go +++ b/handler/real_time_data_receive.go @@ -17,9 +17,10 @@ var upgrader = websocket.Upgrader{ // RealTimeDataReceivehandler define real time data receive and process API func RealTimeDataReceivehandler(c *gin.Context) { + ctx := c.Request.Context() conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - logger.Error(c, "upgrade http protocol to websocket protocal failed", "error", err) + logger.Error(ctx, "upgrade http protocol to websocket protocal failed", "error", err) return } defer conn.Close() @@ -27,17 +28,17 @@ func RealTimeDataReceivehandler(c *gin.Context) { for { messageType, p, err := conn.ReadMessage() if err != nil { - logger.Error(c, "read message from websocket connection failed", "error", err) + logger.Error(ctx, "read message from websocket connection failed", "error", err) respByte := processResponse(-1, "read message from websocket connection failed", nil) if len(respByte) == 0 { - logger.Error(c, "process message from byte failed", "error", err) + logger.Error(ctx, "process message from byte failed", "error", err) continue } err = conn.WriteMessage(messageType, respByte) if err != nil { - logger.Error(c, "write message to websocket connection failed", "error", err) + logger.Error(ctx, "write message to websocket connection failed", "error", err) continue } continue @@ -46,17 +47,17 @@ func RealTimeDataReceivehandler(c *gin.Context) { var request network.RealTimeDataReceiveRequest err = jsoniter.Unmarshal([]byte(p), &request) if err != nil { - logger.Error(c, "unmarshal message from byte failed", "error", err) + logger.Error(ctx, "unmarshal message from byte failed", "error", err) respByte := processResponse(-1, "unmarshal message from byte failed", nil) if len(respByte) == 0 { - logger.Error(c, "process message from byte failed", "error", err) + logger.Error(ctx, "process message from byte failed", "error", err) continue } err = conn.WriteMessage(messageType, respByte) if err != nil { - logger.Error(c, "write message to websocket connection failed", "error", err) + logger.Error(ctx, "write message to websocket connection failed", "error", err) continue } continue @@ -70,13 +71,13 @@ func RealTimeDataReceivehandler(c *gin.Context) { } respByte := processResponse(0, "success", payload) if len(respByte) == 0 { - logger.Error(c, "process message from byte failed", "error", err) + logger.Error(ctx, "process message from byte failed", "error", err) continue } err = conn.WriteMessage(messageType, respByte) if err != nil { - logger.Error(c, "write message to websocket connection failed", "error", err) + logger.Error(ctx, "write message to websocket connection failed", "error", err) continue } } diff --git a/handler/real_time_data_subscription.go b/handler/real_time_data_subscription.go index 340f48b..2680304 100644 --- a/handler/real_time_data_subscription.go +++ b/handler/real_time_data_subscription.go @@ -77,12 +77,13 @@ func init() { // // @Router /monitors/data/subscriptions [post] func RealTimeSubHandler(c *gin.Context) { + ctx := c.Request.Context() var request network.RealTimeSubRequest var subAction string var clientID string if err := c.ShouldBindJSON(&request); err != nil { - logger.Error(c, "failed to unmarshal real time query request", "error", err) + logger.Error(ctx, "failed to unmarshal real time query request", "error", err) renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) return } @@ -91,7 +92,7 @@ func RealTimeSubHandler(c *gin.Context) { subAction = request.Action id, err := uuid.NewV4() if err != nil { - logger.Error(c, "failed to generate client id", "error", err) + logger.Error(ctx, "failed to generate client id", "error", err) renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil) return } @@ -114,9 +115,9 @@ func RealTimeSubHandler(c *gin.Context) { switch subAction { case constants.SubStartAction: - results, err := globalSubState.CreateConfig(c, tx, clientID, request.Measurements) + results, err := globalSubState.CreateConfig(ctx, tx, clientID, request.Measurements) if err != nil { - logger.Error(c, "create real time data subscription config failed", "error", err) + logger.Error(ctx, "create real time data subscription config failed", "error", err) renderRespFailure(c, constants.RespCodeFailed, err.Error(), network.RealTimeSubPayload{ ClientID: clientID, TargetResults: results, @@ -130,9 +131,9 @@ func RealTimeSubHandler(c *gin.Context) { }) return case constants.SubStopAction: - results, err := globalSubState.RemoveTargets(c, clientID, request.Measurements) + results, err := globalSubState.RemoveTargets(ctx, clientID, request.Measurements) if err != nil { - logger.Error(c, "remove target to real time data subscription config failed", "error", err) + logger.Error(ctx, "remove target to real time data subscription config failed", "error", err) renderRespFailure(c, constants.RespCodeFailed, err.Error(), network.RealTimeSubPayload{ ClientID: clientID, TargetResults: results, @@ -146,9 +147,9 @@ func RealTimeSubHandler(c *gin.Context) { }) return case constants.SubAppendAction: - results, err := globalSubState.AppendTargets(c, tx, clientID, request.Measurements) + results, err := globalSubState.AppendTargets(ctx, tx, clientID, request.Measurements) if err != nil { - logger.Error(c, "append target to real time data subscription config failed", "error", err) + logger.Error(ctx, "append target to real time data subscription config failed", "error", err) renderRespFailure(c, constants.RespCodeFailed, err.Error(), network.RealTimeSubPayload{ ClientID: clientID, TargetResults: results, @@ -162,9 +163,9 @@ func RealTimeSubHandler(c *gin.Context) { }) return case constants.SubUpdateAction: - results, err := globalSubState.UpdateTargets(c, tx, clientID, request.Measurements) + results, err := globalSubState.UpdateTargets(ctx, tx, clientID, request.Measurements) if err != nil { - logger.Error(c, "update target to real time data subscription config failed", "error", err) + logger.Error(ctx, "update target to real time data subscription config failed", "error", err) renderRespFailure(c, constants.RespCodeFailed, err.Error(), network.RealTimeSubPayload{ ClientID: clientID, TargetResults: results, @@ -179,7 +180,7 @@ func RealTimeSubHandler(c *gin.Context) { return default: err := fmt.Errorf("%w: request action is %s", common.ErrUnsupportedSubAction, request.Action) - logger.Error(c, "unsupported action of real time data subscription request", "error", err) + logger.Error(ctx, "unsupported action of real time data subscription request", "error", err) requestTargetsCount := processRealTimeRequestCount(request.Measurements) results := processRealTimeRequestTargets(request.Measurements, requestTargetsCount, constants.CodeUnsupportSubOperation, err) renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), network.RealTimeSubPayload{ diff --git a/logger/facede.go b/logger/facede.go index 1533cdf..f2ea9c4 100644 --- a/logger/facede.go +++ b/logger/facede.go @@ -18,6 +18,8 @@ type facade struct { _logger *zap.Logger } +const facadeCallerSkip = 2 + // Debug define facade func of debug level log func Debug(ctx context.Context, msg string, kv ...any) { logFacade().log(ctx, zapcore.DebugLevel, msg, kv...) @@ -39,12 +41,16 @@ func Error(ctx context.Context, msg string, kv ...any) { } func (f *facade) log(ctx context.Context, lvl zapcore.Level, msg string, kv ...any) { - f.logSkip(ctx, lvl, 0, msg, kv...) + f.logSkip(ctx, lvl, 1, msg, kv...) } func (f *facade) logSkip(ctx context.Context, lvl zapcore.Level, extraSkip int, msg string, kv ...any) { fields := makeLogFieldsSkip(ctx, extraSkip, kv...) - ce := f._logger.Check(lvl, msg) + logger := f._logger + if extraSkip > 0 { + logger = logger.WithOptions(zap.AddCallerSkip(extraSkip)) + } + ce := logger.Check(lvl, msg) ce.Write(fields...) } @@ -66,7 +72,7 @@ func InfoSkip(ctx context.Context, extraSkip int, msg string, kv ...any) { func logFacade() *facade { fOnce.Do(func() { f = &facade{ - _logger: GetLoggerInstance(), + _logger: GetLoggerInstance().WithOptions(zap.AddCallerSkip(facadeCallerSkip)), } }) return f diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index 259aee0..c611e68 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -1,6 +1,11 @@ package model -import "testing" +import ( + "fmt" + "testing" + + "modelRT/constants" +) func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) { tests := []struct { @@ -38,3 +43,59 @@ func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) { }) } } + +func TestFallbackSpecificSetKey(t *testing.T) { + tests := []struct { + name string + hierarchy constants.RecommendHierarchyType + inputSlice []string + want string + wantOK bool + }{ + { + name: "zone fallback uses grid specific set", + hierarchy: constants.ZoneRecommendHierarchyType, + inputSlice: []string{"grid000", "z"}, + want: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, "grid000"), + wantOK: true, + }, + { + name: "component tag fallback uses previous nspath token", + hierarchy: constants.CompTagRecommendHierarchyType, + inputSlice: []string{"grid000", "I"}, + want: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "grid000"), + wantOK: true, + }, + { + name: "measurement fallback skips config token and uses component tag", + hierarchy: constants.MeasTagRecommendHierarchyType, + inputSlice: []string{"nspath", "comp_tag", "config", "m"}, + want: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"), + wantOK: true, + }, + { + name: "config fallback has no parent specific set", + hierarchy: constants.ConfigRecommendHierarchyType, + inputSlice: []string{"nspath", "comp_tag", ""}, + wantOK: false, + }, + { + name: "missing previous token", + hierarchy: constants.ZoneRecommendHierarchyType, + inputSlice: []string{"z"}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := fallbackSpecificSetKey(tt.hierarchy, tt.inputSlice) + if ok != tt.wantOK { + t.Fatalf("expected ok %t, got %t", tt.wantOK, ok) + } + if got != tt.want { + t.Fatalf("expected key %q, got %q", tt.want, got) + } + }) + } +} From d8668afa463fe64e0528040aaf6438c5205fba52 Mon Sep 17 00:00:00 2001 From: douxu Date: Wed, 8 Jul 2026 14:41:13 +0800 Subject: [PATCH 08/14] refactor: simplify redis recommendation concurrency - replace fan-in channel recommendation searches with errgroup-based execution - return SearchResult directly from Redis recommendation helper functions - fallback leading-dot recommendation input to initial token suggestions - remove unused recommendLenType parameter from fuzzy member set validation - clean up small model selection and naming issues --- handler/async_task_create_handler.go | 4 + model/attribute_model.go | 4 +- model/model_select.go | 8 +- model/redis_recommend.go | 339 ++++++++++++--------------- util/time.go | 4 +- 5 files changed, 157 insertions(+), 202 deletions(-) diff --git a/handler/async_task_create_handler.go b/handler/async_task_create_handler.go index d1ff912..6da67c5 100644 --- a/handler/async_task_create_handler.go +++ b/handler/async_task_create_handler.go @@ -2,6 +2,8 @@ package handler import ( + "fmt" + "modelRT/constants" "modelRT/database" "modelRT/logger" @@ -152,6 +154,8 @@ func validateBatchImportParams(params map[string]any) bool { func validateTestTaskParams(params map[string]any) bool { // Test task has optional parameters, all are valid // sleep_duration defaults to 60 seconds if not provided + // TODO Add more validation logic for test task parameters if needed + fmt.Println(params) return true } diff --git a/model/attribute_model.go b/model/attribute_model.go index 601d156..6654dae 100644 --- a/model/attribute_model.go +++ b/model/attribute_model.go @@ -90,6 +90,6 @@ func (s *ShortAttrInfo) IsLocal() bool { } // GetAttrValue define return the attribute value -func (l *ShortAttrInfo) GetAttrValue() any { - return l.AttrValue +func (s *ShortAttrInfo) GetAttrValue() any { + return s.AttrValue } diff --git a/model/model_select.go b/model/model_select.go index 042a867..601eeba 100644 --- a/model/model_select.go +++ b/model/model_select.go @@ -1,3 +1,4 @@ +// Package model define model struct of model runtime service package model import ( @@ -7,11 +8,12 @@ import ( // SelectModelByType define select the data structure for parsing based on the input model type func SelectModelByType(modelType int) BasicModelInterface { - if modelType == constants.BusbarType { + switch modelType { + case constants.BusbarType: return &orm.BusbarSection{} - } else if modelType == constants.AsyncMotorType { + case constants.AsyncMotorType: return &orm.AsyncMotor{} - } else if modelType == constants.DemoType { + case constants.DemoType: return &orm.Demo{} } return nil diff --git a/model/redis_recommend.go b/model/redis_recommend.go index fa0db57..5249872 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "strings" + "sync" "modelRT/constants" "modelRT/diagram" @@ -16,6 +17,7 @@ import ( "github.com/RediSearch/redisearch-go/v2/redisearch" redigo "github.com/gomodule/redigo/redis" "github.com/redis/go-redis/v9" + "golang.org/x/sync/errgroup" ) // SearchResult define struct to store redis query recommend search result @@ -36,12 +38,44 @@ func InitAutocompleterWithPool(pool *redigo.Pool) { ac = redisearch.NewAutocompleterFromPool(pool, constants.RedisSearchDictName) } +type recommendSearchJob func() SearchResult + +func runRecommendSearches(ctx context.Context, logMsg string, filterCompNSPath bool, jobs ...recommendSearchJob) map[string]SearchResult { + results := make(map[string]SearchResult) + var mu sync.Mutex + var group errgroup.Group + + for _, job := range jobs { + job := job + group.Go(func() error { + result := job() + if result.Err != nil { + return fmt.Errorf("%s: %w", result.RecommendType, result.Err) + } + + if filterCompNSPath && result.RecommendType == constants.CompNSPathRecommendHierarchyType { + result.QueryDatas = filterLocalNSPathResults(result.QueryDatas) + } + + mu.Lock() + results[result.RecommendType.String()] = result + mu.Unlock() + return nil + }) + } + + if err := group.Wait(); err != nil { + logger.Error(ctx, logMsg, "error", err) + } + return results +} + // levelOneRedisSearch 处理第一段输入的搜索, 例如 grid 或本地 nspath。 -func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, searchInput string, searchRedisKey string, fanInChan chan SearchResult) { +func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, searchInput string, searchRedisKey string) (result SearchResult) { defer func() { if r := recover(); r != nil { logger.Error(ctx, "searchFunc panicked", "panic", r) - fanInChan <- SearchResult{ + result = SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, @@ -54,64 +88,59 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const if err != nil { logger.Error(ctx, "redis membership check failed", "key", searchRedisKey, "member", searchInput, "op", "SIsMember", "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } // the input key is the complete hierarchical value if exists { - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: []string{"."}, IsFuzzy: false, Err: nil, } - return } // process fuzzy search result recommends, offset, err := runFuzzySearch(ctx, rdb, searchInput, "", hierarchy, recommendLenType) if err != nil { logger.Error(ctx, fmt.Sprintf("fuzzy search failed for %s hierarchical", util.GetLevelStrByRdsKey(searchRedisKey)), "search_input", searchInput, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } if len(recommends) > 0 { - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, Offset: offset, Err: nil, } - return } recommends, err = fallbackAllCurrentLevel(ctx, rdb, hierarchy) if err != nil { logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: true, IsFallback: true, Err: err, } - return } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, @@ -124,165 +153,102 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const // RedisSearchRecommend define func of redis search by input string and return recommend results func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchResult { rdb := diagram.GetRedisClientInstance() - finalizeResults := func(results map[string]SearchResult) map[string]SearchResult { + finalizeResults := func(normalizeInput string, results map[string]SearchResult) map[string]SearchResult { results = suppressFallbackResultsWhenConcreteHitExists(results) - return normalizeRecommendResults(input, results) + return normalizeRecommendResults(normalizeInput, results) } - if input == "" { - fanInChan := make(chan SearchResult, 2) - // return all grid tagname - go getAllKeyByGridLevel(ctx, rdb, fanInChan) - // return all component nspath - go getAllKeyByNSPathLevel(ctx, rdb, fanInChan) - results := make(map[string]SearchResult) - for range 2 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "return all keys at the special level from redis failed", "recommend_type", result.RecommendType, "error", result.Err) - continue - } - - if result.RecommendType == constants.CompNSPathRecommendHierarchyType { - result.QueryDatas = filterLocalNSPathResults(result.QueryDatas) - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + if input == "" || shouldFallbackToInitialRecommend(input) { + results := runRecommendSearches(ctx, "return all keys at the special level from redis failed", true, + func() SearchResult { + return getAllKeyByGridLevel(ctx, rdb) + }, + func() SearchResult { + return getAllKeyByNSPathLevel(ctx, rdb) + }, + ) + return finalizeResults("", results) } inputSlice := strings.Split(input, ".") inputSliceLen := len(inputSlice) - fanInChan := make(chan SearchResult, 4) switch inputSliceLen { case 1: searchInput := inputSlice[0] - // grid tagname search - go levelOneRedisSearch(ctx, rdb, constants.GridRecommendHierarchyType, constants.FullRecommendLength, searchInput, constants.RedisAllGridSetKey, fanInChan) - // component nspath search - go levelOneRedisSearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, searchInput, constants.RedisAllCompNSPathSetKey, fanInChan) - - results := make(map[string]SearchResult) - // TODO 后续根据支持的数据标识语法长度,进行值的变更 - for range 2 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "exec redis fuzzy search by key failed", "recommend_type", result.RecommendType, "error", result.Err) - continue - } - - if result.RecommendType == constants.CompNSPathRecommendHierarchyType { - result.QueryDatas = filterLocalNSPathResults(result.QueryDatas) - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "exec redis fuzzy search by key failed", true, + func() SearchResult { + return levelOneRedisSearch(ctx, rdb, constants.GridRecommendHierarchyType, constants.FullRecommendLength, searchInput, constants.RedisAllGridSetKey) + }, + func() SearchResult { + return levelOneRedisSearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, searchInput, constants.RedisAllCompNSPathSetKey) + }, + ) + return finalizeResults(input, results) case 2: - // zone tagname search - go handleLevelFuzzySearch(ctx, rdb, constants.ZoneRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllZoneSetKey, inputSlice, fanInChan) - // component tagname search - go handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllCompTagSetKey, inputSlice, fanInChan) - // measurement tagname search by token4-token7 shorthand - go handleNSPathMeasSearch(ctx, rdb, inputSlice, fanInChan) - results := make(map[string]SearchResult) - for range 3 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) - continue - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.ZoneRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllZoneSetKey, inputSlice) + }, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllCompTagSetKey, inputSlice) + }, + func() SearchResult { + return handleNSPathMeasSearch(ctx, rdb, inputSlice) + }, + ) + return finalizeResults(input, results) case 3: - // station tanname search - go handleLevelFuzzySearch(ctx, rdb, constants.StationRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllStationSetKey, inputSlice, fanInChan) - // config search - go handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice, fanInChan) - - results := make(map[string]SearchResult) - for range 2 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) - continue - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.StationRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllStationSetKey, inputSlice) + }, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) + }, + ) + return finalizeResults(input, results) case 4: - // component nspath search - go handleLevelFuzzySearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompNSPathSetKey, inputSlice, fanInChan) - // measurement tagname search - go handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice, fanInChan) - - results := make(map[string]SearchResult) - for range 2 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) - continue - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompNSPathSetKey, inputSlice) + }, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) + }, + ) + return finalizeResults(input, results) case 5: - // component tagname search - go handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice, fanInChan) - - results := make(map[string]SearchResult) - for range 1 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) - continue - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice) + }, + ) + return finalizeResults(input, results) case 6: - // config search - go handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice, fanInChan) - - results := make(map[string]SearchResult) - for range 1 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) - continue - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice) + }, + ) + return finalizeResults(input, results) case 7: - // measurement tagname search - go handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice, fanInChan) - - results := make(map[string]SearchResult) - for range 1 { - result := <-fanInChan - if result.Err != nil { - logger.Error(ctx, "query all keys at the special level from redis failed", "query_key", result.RecommendType, "error", result.Err) - continue - } - results[result.RecommendType.String()] = result - } - - return finalizeResults(results) + results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, + func() SearchResult { + return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice) + }, + ) + return finalizeResults(input, results) default: logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen) return nil } } +func shouldFallbackToInitialRecommend(input string) bool { + return strings.HasPrefix(input, ".") +} + // suppressFallbackResultsWhenConcreteHitExists 有明确命中时丢弃兜底结果, 避免返回过宽推荐。 func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult { hasConcreteHit := false @@ -494,23 +460,22 @@ func getAllSetKeyByHierarchy(hierarchy constants.RecommendHierarchyType) (string } // getAllKeyByGridLevel 返回所有 grid tag, 用于空输入时的初始推荐。 -func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { +func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client) SearchResult { setKey := constants.RedisAllGridSetKey hierarchy := constants.GridRecommendHierarchyType members, err := rdb.SMembers(ctx, setKey).Result() if err != nil && err != redigo.ErrNil { logger.Error(ctx, "get all members from redis by special key failed", "key", setKey, "op", "SMembers", "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: members, IsFuzzy: false, @@ -519,23 +484,22 @@ func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan } // getAllKeyByNSPathLevel 返回所有组件 nspath, 用于空输入时的初始推荐。 -func getAllKeyByNSPathLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { +func getAllKeyByNSPathLevel(ctx context.Context, rdb *redis.Client) SearchResult { queryKey := constants.RedisAllCompNSPathSetKey hierarchy := constants.CompNSPathRecommendHierarchyType members, err := rdb.SMembers(ctx, queryKey).Result() if err != nil && err != redigo.ErrNil { logger.Error(ctx, "get all members by special key failed", "key", queryKey, "op", "SMembers", "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: members, IsFuzzy: false, @@ -611,7 +575,7 @@ func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefi } // handleNSPathMeasSearch 处理 nspath.measurement 的简写搜索路径。 -func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice []string, fanInChan chan SearchResult) { +func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice []string) SearchResult { hierarchy := constants.MeasTagRecommendHierarchyType nsPath := inputSlice[0] searchInput := inputSlice[1] @@ -619,22 +583,20 @@ func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice [ nsPathExists, err := rdb.SIsMember(ctx, constants.RedisAllCompNSPathSetKey, nsPath).Result() if err != nil { logger.Error(ctx, "check nspath exist from redis set failed", "key", constants.RedisAllCompNSPathSetKey, "nspath", nsPath, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } if !nsPathExists { - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: []string{}, IsFuzzy: false, Err: nil, } - return } queryKey := fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, nsPath) @@ -642,57 +604,52 @@ func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice [ members, err := rdb.SMembers(ctx, queryKey).Result() if err != nil && err != redis.Nil { logger.Error(ctx, "query measurement tags by nspath failed", "key", queryKey, "nspath", nsPath, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: combineNSPathMeasResults(nsPath, members), IsFuzzy: false, Err: nil, } - return } measTagExists, err := rdb.SIsMember(ctx, queryKey, searchInput).Result() if err != nil { logger.Error(ctx, "check measurement tag exist from nspath set failed", "key", queryKey, "meas_tag", searchInput, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } if measTagExists { - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: []string{""}, IsFuzzy: false, Err: nil, } - return } recommends, offset, err := runNSPathMeasFuzzySearch(ctx, nsPath, searchInput) if err != nil { - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: true, Err: err, } - return } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, @@ -760,7 +717,7 @@ func splitLastToken(term string) (string, string) { } // handleLevelFuzzySearch 处理第二层及以后层级的精确、模糊和兜底推荐。 -func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, redisSetKey string, inputSlice []string, fanInChan chan SearchResult) { +func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, redisSetKey string, inputSlice []string) SearchResult { inputSliceLen := len(inputSlice) searchInputIndex := inputSliceLen - 1 searchInput := inputSlice[searchInputIndex] @@ -783,36 +740,33 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co keyExists, err := rdb.SIsMember(ctx, redisSetKey, specificalKey).Result() if err != nil { logger.Error(ctx, "check key exist from redis set failed ", "key", redisSetKey, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } if !keyExists { - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: []string{}, IsFuzzy: false, Err: nil, } - return } } members, err := queryMemberFromSpecificsLevel(ctx, rdb, hierarchy, specificalKey) if err != nil && err != redis.Nil { logger.Error(ctx, "query members from redis by special key failed", "key", specificalKey, "member", searchInput, "op", "SMember", "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } recommandResults := combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members) @@ -838,25 +792,23 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } recommandResults = secondConfirmResults } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommandResults, IsFuzzy: false, Err: nil, } - return } keyExists, err := rdb.SIsMember(ctx, redisSetKey, searchInput).Result() if err != nil { logger.Error(ctx, "check key exist from redis set failed ", "key", redisSetKey, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } if keyExists { @@ -867,26 +819,24 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co QueryData = []string{"."} } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: QueryData, IsFuzzy: false, Err: nil, } - return } // start redis fuzzy search recommends, offset, err := runFuzzySearch(ctx, rdb, searchInput, searchPrefix, hierarchy, recommendLenType) if err != nil { logger.Error(ctx, "fuzzy search failed by hierarchy", "hierarchy", hierarchy, "search_input", searchInput, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } isFallback := false @@ -895,20 +845,19 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co recommends, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, inputSlice) if err != nil { logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: true, IsFallback: true, Err: err, } - return } isFallback = true offset = 0 } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, @@ -992,7 +941,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, recommendLenType, comparePrefix, termLastPart) + exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, comparePrefix, termLastPart) if err != nil || !exists { logger.Info(ctx, "fuzzy recommend term not found in specific redis set", "hierarchy", hierarchy, @@ -1034,8 +983,8 @@ func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { } // fuzzyRecommendMemberExists 校验模糊候选末级 token 是否存在于对应 Redis set。 -func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string, termLastPart string) (bool, error) { - setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, recommendLenType, comparePrefix) +func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, comparePrefix string, termLastPart string) (bool, error) { + setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, comparePrefix) if !ok { return true, nil } @@ -1043,7 +992,7 @@ func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarch } // fuzzyRecommendMemberSetKey 返回模糊候选校验时应使用的 Redis set key。 -func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string) (string, bool) { +func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comparePrefix string) (string, bool) { switch hierarchy { case constants.GridRecommendHierarchyType: return constants.RedisAllGridSetKey, true diff --git a/util/time.go b/util/time.go index f51b210..7c75e99 100644 --- a/util/time.go +++ b/util/time.go @@ -6,8 +6,8 @@ import ( "time" ) -// GenNanoTsStr define func to generate nanosecond timestamp string by current time -func GenNanoTsStr() string { +// GenNanoTSStr define func to generate nanosecond timestamp string by current time +func GenNanoTSStr() string { now := time.Now() nanoseconds := now.UnixNano() timestampStr := strconv.FormatInt(nanoseconds, 10) From 180b0f78433cb6c31053f9bb8d3fb8cf1043d044 Mon Sep 17 00:00:00 2001 From: douxu Date: Wed, 8 Jul 2026 17:19:07 +0800 Subject: [PATCH 09/14] test: cover initial recommend fallback inputs - add coverage for leading-dot recommendation inputs - verify normal recommendation inputs do not fallback to initial suggestions --- model/redis_recommend_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index c611e68..3c5f4c4 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -99,3 +99,28 @@ func TestFallbackSpecificSetKey(t *testing.T) { }) } } + +func TestShouldFallbackToInitialRecommend(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {input: "", want: false}, + {input: ".", want: true}, + {input: ".x", want: true}, + {input: "..x", want: true}, + {input: "...x", want: true}, + {input: "grid000", want: false}, + {input: "grid000.", want: false}, + {input: "grid000.zone000", want: false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := shouldFallbackToInitialRecommend(tt.input) + if got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) + } +} From 6f78d8e34167bd90a3487822f73b4f7fca6a2df7 Mon Sep 17 00:00:00 2001 From: douxu Date: Thu, 9 Jul 2026 16:31:19 +0800 Subject: [PATCH 10/14] 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 From 578f805b57417b76e7f377cc3e34b571ea00b99c Mon Sep 17 00:00:00 2001 From: douxu Date: Fri, 10 Jul 2026 11:02:56 +0800 Subject: [PATCH 11/14] fix: correct measurement recommendation fuzzy matching and hierarchy fallback - Fix component tag suggestion writes where full paths were overwritten by local paths - Add exact token checks for trailing-dot inputs to avoid trimming valid hierarchy levels - Improve config/meas fallback by trimming the input prefix before filtering candidates, avoiding full-level fallback output - Use hash-based validation for config and measurement recommendation ownership instead of long-prefix autocomplete exact queries --- model/measurement_group_recommend_model.go | 42 +- .../measurement_group_recommend_model_test.go | 21 + model/redis_recommend.go | 256 +++++++++--- model/redis_recommend_test.go | 369 ++++++++++++++++++ 4 files changed, 627 insertions(+), 61 deletions(-) create mode 100644 model/measurement_group_recommend_model_test.go diff --git a/model/measurement_group_recommend_model.go b/model/measurement_group_recommend_model.go index 7985174..a97edc6 100644 --- a/model/measurement_group_recommend_model.go +++ b/model/measurement_group_recommend_model.go @@ -22,6 +22,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement compTagToFullPath := make(map[string]string) isLocalCompTagToFullPath := make(map[string]string) + var allErrs []error zoneToGridPath := make(map[string]string) for gridTag, zoneTags := range measSet.GridToZoneTags { @@ -62,12 +63,21 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement pipe.SAdd(ctx, key, members) } } + safeAddTerms := func(sug []redisearch.Suggestion) { + if len(sug) == 0 { + return + } + if err := ac.AddTerms(sug...); err != nil { + logger.Error(ctx, "add measurement group recommend suggestions failed", "error", err) + allErrs = append(allErrs, err) + } + } safeSAdd(constants.RedisAllGridSetKey, measSet.AllGridTags) gridSug := util.MapSlice(measSet.AllGridTags, func(gridTag string) redisearch.Suggestion { return redisearch.Suggestion{Term: gridTag, Score: constants.DefaultScore} }) - ac.AddTerms(gridSug...) + safeAddTerms(gridSug) safeSAdd(constants.RedisAllZoneSetKey, measSet.AllZoneTags) safeSAdd(constants.RedisAllStationSetKey, measSet.AllStationTags) @@ -83,7 +93,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement return redisearch.Suggestion{Term: fmt.Sprintf("%s.%s", gridTag, zoneTag), Score: constants.DefaultScore} }) safeSAdd(fmt.Sprintf(constants.RedisSpecGridZoneSetKey, gridTag), zoneTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the zone -> stations hierarchy @@ -101,7 +111,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement }) safeSAdd(fmt.Sprintf(constants.RedisSpecZoneStationSetKey, zoneTag), stationTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the station -> component nspaths hierarchy @@ -122,7 +132,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement sug = append(sug, redisearch.Suggestion{Term: nsPath, Score: constants.DefaultScore}) } safeSAdd(fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, stationTag), compNSPaths) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the component nspath -> component tags hierarchy @@ -136,20 +146,17 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement } for _, compTag := range compTags { - fullPath := fmt.Sprintf("%s.%s.%s", parentPath, compNSPath, compTag) - compTagToFullPath[compTag] = fullPath - fullPath = fmt.Sprintf("%s.%s", compNSPath, compTag) - isLocalCompTagToFullPath[compTag] = fullPath + fullTerm, localTerm := compTagSuggestionTerms(parentPath, compNSPath, compTag) + compTagToFullPath[compTag] = fullTerm + isLocalCompTagToFullPath[compTag] = localTerm // add redis fuzzy search suggestion for token1-token7 type - term := fullPath - sug = append(sug, redisearch.Suggestion{Term: term, Score: constants.DefaultScore}) + sug = append(sug, redisearch.Suggestion{Term: fullTerm, Score: constants.DefaultScore}) // add redis fuzzy search suggestion for token4-token7 type - term = fmt.Sprintf("%s.%s", compNSPath, compTag) - sug = append(sug, redisearch.Suggestion{Term: term, Score: constants.DefaultScore}) + sug = append(sug, redisearch.Suggestion{Term: localTerm, Score: constants.DefaultScore}) } safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, compNSPath), compTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the component tag -> measurement tags hierarchy @@ -187,7 +194,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement if len(measTags) > 0 { pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...) } - ac.AddTerms(sug...) + safeAddTerms(sug) } // building the component nspath -> measurement tags hierarchy for token4-token7 shorthand @@ -200,10 +207,9 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement }) } safeSAdd(fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, compNSPath), measTags) - ac.AddTerms(sug...) + safeAddTerms(sug) } - var allErrs []error cmders, execErr := pipe.Exec(ctx) if execErr != nil { logger.Error(ctx, "pipeline execution failed", "error", execErr) @@ -227,3 +233,7 @@ func TraverseMeasurementGroupTables(ctx context.Context, measSet orm.Measurement return compTagToFullPath, isLocalCompTagToFullPath, nil } + +func compTagSuggestionTerms(parentPath string, compNSPath string, compTag string) (string, string) { + return fmt.Sprintf("%s.%s.%s", parentPath, compNSPath, compTag), fmt.Sprintf("%s.%s", compNSPath, compTag) +} diff --git a/model/measurement_group_recommend_model_test.go b/model/measurement_group_recommend_model_test.go new file mode 100644 index 0000000..3ff4713 --- /dev/null +++ b/model/measurement_group_recommend_model_test.go @@ -0,0 +1,21 @@ +package model + +import "testing" + +func TestCompTagSuggestionTermsKeepFullAndLocalPaths(t *testing.T) { + parentPath := "grid000.zone000.station000" + compNSPath := "110kV_TV-demoProject" + compTag := "cable_22-testProject1110kV_TV" + + fullTerm, localTerm := compTagSuggestionTerms(parentPath, compNSPath, compTag) + + wantFullTerm := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV" + if fullTerm != wantFullTerm { + t.Fatalf("expected full suggestion term %q, got %q", wantFullTerm, fullTerm) + } + + wantLocalTerm := "110kV_TV-demoProject.cable_22-testProject1110kV_TV" + if localTerm != wantLocalTerm { + t.Fatalf("expected local suggestion term %q, got %q", wantLocalTerm, localTerm) + } +} diff --git a/model/redis_recommend.go b/model/redis_recommend.go index c42c6ca..7483207 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -171,9 +171,9 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return finalizeResults("", results) } - inputSlice := strings.Split(input, ".") + searchInput := normalizeTrailingDotSearchInput(ctx, rdb, input) + inputSlice := strings.Split(searchInput, ".") inputSliceLen := len(inputSlice) - switch inputSliceLen { case 1: searchInput := inputSlice[0] @@ -185,7 +185,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return levelOneRedisSearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, searchInput, constants.RedisAllCompNSPathSetKey) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 2: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { @@ -198,7 +198,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleNSPathMeasSearch(ctx, rdb, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 3: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { @@ -208,7 +208,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 4: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { @@ -218,28 +218,28 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 5: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { return handleLevelFuzzySearch(ctx, rdb, constants.CompTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompTagSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 6: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { return handleLevelFuzzySearch(ctx, rdb, constants.ConfigRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllConfigSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) case 7: results := runRecommendSearches(ctx, "query all keys at the special level from redis failed", false, func() SearchResult { return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice) }, ) - return finalizeResults(input, results) + return finalizeResults(searchInput, results) default: logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen) return nil @@ -250,6 +250,92 @@ func shouldFallbackToInitialRecommend(input string) bool { return strings.HasPrefix(input, ".") } +// normalizeTrailingDotSearchInput 忽略误输入的尾随点号, 让前一段 token 继续参与模糊搜索。 +func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, input string) string { + if input == "" || !strings.HasSuffix(input, ".") || strings.HasSuffix(input, "..") { + return input + } + + trimmedInput := strings.TrimSuffix(input, ".") + if trimmedInput == "" { + return input + } + + checks := exactMatchChecksForInput(strings.Split(trimmedInput, ".")) + if len(checks) == 0 { + return input + } + + for _, check := range checks { + exists, err := rdb.SIsMember(ctx, check.setKey, check.member).Result() + if err != nil { + logger.Warn(ctx, "check trailing dot input exact match failed", "input", input, "key", check.setKey, "member", check.member, "error", err) + return input + } + if exists { + return input + } + } + return trimmedInput +} + +type exactMatchCheck struct { + setKey string + member string +} + +func exactMatchChecksForInput(inputSlice []string) []exactMatchCheck { + inputSliceLen := len(inputSlice) + if inputSliceLen == 0 { + return nil + } + + lastToken := inputSlice[inputSliceLen-1] + if lastToken == "" { + return nil + } + + switch inputSliceLen { + case 1: + return []exactMatchCheck{ + {setKey: constants.RedisAllGridSetKey, member: lastToken}, + {setKey: constants.RedisAllCompNSPathSetKey, member: lastToken}, + } + case 2: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, inputSlice[0]), member: lastToken}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, inputSlice[0]), member: lastToken}, + {setKey: constants.RedisAllCompTagSetKey, member: lastToken}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, inputSlice[0]), member: lastToken}, + } + case 3: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecZoneStationSetKey, inputSlice[1]), member: lastToken}, + {setKey: constants.RedisAllConfigSetKey, member: lastToken}, + } + case 4: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, inputSlice[2]), member: lastToken}, + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[1]), member: lastToken}, + } + case 5: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, inputSlice[3]), member: lastToken}, + {setKey: constants.RedisAllCompTagSetKey, member: lastToken}, + } + case 6: + return []exactMatchCheck{ + {setKey: constants.RedisAllConfigSetKey, member: lastToken}, + } + case 7: + return []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[4]), member: lastToken}, + } + default: + return nil + } +} + // suppressFallbackResultsWhenConcreteHitExists 有明确命中时丢弃兜底结果, 避免返回过宽推荐。 func suppressFallbackResultsWhenConcreteHitExists(results map[string]SearchResult) map[string]SearchResult { hasConcreteHit := false @@ -396,30 +482,101 @@ func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy c } // fallbackCurrentLevelByInput 兜底时先校验上下级关系, 避免错误前缀返回全层级数据。 -func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, inputSlice []string) ([]string, error) { +func fallbackCurrentLevelByInput(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, inputSlice []string) ([]string, int, error) { allSetKey, ok := getAllSetKeyByHierarchy(hierarchy) if !ok { - return nil, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) + return nil, 0, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy) + } + + if hierarchy == constants.ConfigRecommendHierarchyType { + return fallbackConfigByInput(ctx, rdb, allSetKey, recommendLenType, inputSlice) + } + if hierarchy == constants.MeasTagRecommendHierarchyType { + return fallbackMeasByInput(ctx, rdb, allSetKey, recommendLenType, inputSlice) } specificSetKey, ok := fallbackSpecificSetKey(hierarchy, inputSlice) if !ok { - return fallbackAllCurrentLevel(ctx, rdb, hierarchy) + members, err := fallbackAllCurrentLevel(ctx, rdb, hierarchy) + return members, 0, err } count, err := rdb.SInterCard(ctx, 1, allSetKey, specificSetKey).Result() if err != nil && err != redis.Nil { - return nil, err + return nil, 0, err } if count == 0 { - return []string{}, nil + return []string{}, 0, nil } members, err := rdb.SMembers(ctx, specificSetKey).Result() if err != nil && err != redis.Nil { - return nil, err + return nil, 0, err } - return members, nil + return combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, members), recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil +} + +func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { + if len(inputSlice) < 2 { + members, err := fallbackAllCurrentLevel(ctx, rdb, constants.ConfigRecommendHierarchyType) + return members, 0, err + } + + members, err := rdb.SMembers(ctx, allSetKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + + searchInput := inputSlice[len(inputSlice)-1] + matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) + if len(matchedMembers) == 0 { + return []string{}, 0, nil + } + + prefixTokens := inputSlice[:len(inputSlice)-1] + recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) + recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil +} + +func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { + members, err := rdb.SMembers(ctx, allSetKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + + searchInput := inputSlice[len(inputSlice)-1] + matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) + if len(matchedMembers) == 0 { + return []string{}, 0, nil + } + + recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) + recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + + return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil +} + +func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) { + for len([]rune(searchInput)) != 0 { + filtered := make([]string, 0, len(members)) + for _, member := range members { + if strings.HasPrefix(member, searchInput) { + filtered = append(filtered, member) + } + } + if len(filtered) > 0 { + return filtered, searchInput + } + searchInput = trimLastRune(searchInput) + } + return []string{}, "" } // fallbackSpecificSetKey 根据输入切片推导 fallback 应该校验的上一层级 specific set。 @@ -591,6 +748,42 @@ func confirmMeasRecommendResultsByHash(ctx context.Context, rdb *redis.Client, i return confirmedResults } +func confirmConfigRecommendResultsByHash(ctx context.Context, rdb *redis.Client, inputSlice []string, members []string, recommends []string) []string { + compTag, ok := configRecommendCompTag(inputSlice) + if !ok { + return []string{} + } + + confirmedResults := make([]string, 0, len(recommends)) + for index, recommend := range recommends { + if index >= len(members) { + break + } + + hashKey := recommendGroupKey(compTag, members[index]) + exists, err := rdb.Exists(ctx, hashKey).Result() + if err != nil { + logger.Error(ctx, "config recommend hash confirmation failed", "hash_key", hashKey, "error", err) + continue + } + if exists == 0 { + continue + } + confirmedResults = append(confirmedResults, recommend) + } + return confirmedResults +} + +func configRecommendCompTag(inputSlice []string) (string, bool) { + configTokenIndex := len(inputSlice) - 1 + compTagIndex := configTokenIndex - 1 + if compTagIndex < 0 || compTagIndex >= len(inputSlice) { + return "", false + } + compTag := inputSlice[compTagIndex] + return compTag, compTag != "" +} + func recommendGroupTokens(inputSlice []string) (string, string, bool) { searchInputIndex := len(inputSlice) - 1 compTagIndex := searchInputIndex - 2 @@ -844,26 +1037,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co 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 { - results, err := ac.SuggestOpts(res, redisearch.SuggestOptions{ - Num: 1, - Fuzzy: false, - WithScores: false, - WithPayloads: false, - }) - if err != nil { - logger.Error(ctx, "config hierarchy query key second confirmation failed", "query_key", res, "error", err) - continue - } - - if len(results) == 0 { - continue - } - secondConfirmResults = append(secondConfirmResults, res) - } - recommandResults = secondConfirmResults + recommandResults = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults) } return SearchResult{ RecommendType: hierarchy, @@ -915,7 +1089,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co isFallback := false if len(recommends) == 0 { logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err) - recommends, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, inputSlice) + recommends, offset, err = fallbackCurrentLevelByInput(ctx, rdb, hierarchy, recommendLenType, inputSlice) if err != nil { logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err) return SearchResult{ @@ -927,12 +1101,6 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } } isFallback = true - if _, ok := fallbackSpecificSetKey(hierarchy, inputSlice); ok { - recommends = combineQueryResultByInput(hierarchy, recommendLenType, inputSlice, recommends) - offset = recommendPrefixOffset(inputSlice[:len(inputSlice)-1]) - } else { - offset = 0 - } } return SearchResult{ @@ -979,7 +1147,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, logger.Error(ctx, "query key by redis fuzzy search failed", "query_key", fuzzyInput, "error", err) return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) } - if len(results) == 0 { // 如果没有结果,退一步(删除最后一个字符)并继续循环 // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 @@ -1041,7 +1208,6 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, recommends = append(recommends, recommend) } } - if len(recommends) == 0 { searchInput = trimLastRune(searchInput) searchInputLen = len([]rune(searchInput)) diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index 88721d5..a522789 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "reflect" "strings" "testing" @@ -217,6 +218,138 @@ func TestNormalizeRecommendResultsKeepsParentPrefixOffsetForSpecificFallback(t * } } +func TestNormalizeRecommendResultsTrimsConfigFuzzySuffixes(t *testing.T) { + input := "110kV_TV-testProject1.cable_22-testProject1110kV_TV.base_extend.c" + offset := len([]rune(input)) + results := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: []string{ + input + "apacity", + input + "ategory", + input + "urrent", + input + "ode", + }, + IsFuzzy: true, + Offset: offset, + }, + } + + got := normalizeRecommendResults(input, results) + result := got[constants.MeasTagRecommendHierarchyType.String()] + want := []string{"apacity", "ategory", "urrent", "ode"} + if result.Offset != offset { + t.Fatalf("expected offset %d, got %d", offset, result.Offset) + } + if !reflect.DeepEqual(result.QueryDatas, want) { + t.Fatalf("expected trimmed recommends %#v, got %#v", want, result.QueryDatas) + } +} + +func TestNormalizeRecommendResultsTrimsToken4FallbackCandidates(t *testing.T) { + nsPath := "110kV_TV-testProject1" + input := nsPath + ".x" + offset := recommendPrefixOffset([]string{nsPath}) + results := map[string]SearchResult{ + constants.CompTagRecommendHierarchyType.String(): { + RecommendType: constants.CompTagRecommendHierarchyType, + QueryDatas: []string{ + nsPath + ".cable_22-testProject1110kV_TV", + nsPath + ".cable_23-testProject1110kV_TV", + }, + 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) + for key, result := range got { + if result.Offset != offset { + t.Fatalf("expected offset %d for %s, got %d", offset, key, result.Offset) + } + for _, recommend := range result.QueryDatas { + if strings.HasPrefix(recommend, nsPath+".") { + t.Fatalf("expected trimmed token4 fallback recommend for %s, got %s", key, recommend) + } + } + } + if got[constants.CompTagRecommendHierarchyType.String()].QueryDatas[0] != "cable_22-testProject1110kV_TV" { + t.Fatalf("expected token5 suffixes, got %v", got[constants.CompTagRecommendHierarchyType.String()].QueryDatas) + } + if got[constants.MeasTagRecommendHierarchyType.String()].QueryDatas[0] != "IA_rms_CTA-testProject1" { + t.Fatalf("expected token7 suffixes, got %v", got[constants.MeasTagRecommendHierarchyType.String()].QueryDatas) + } +} + +func TestNormalizeRecommendResultsKeepsFullAndLocalConfigMeasurementSuffixesConsistent(t *testing.T) { + fullPrefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.base_extend" + localPrefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV.base_extend" + members := []string{"capacity", "category", "current"} + + fullResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(fullPrefix+".", "."), members), + IsFuzzy: false, + }, + } + localResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(localPrefix+".", "."), members), + IsFuzzy: false, + }, + } + + fullGot := normalizeRecommendResults(fullPrefix+".", fullResults) + localGot := normalizeRecommendResults(localPrefix+".", localResults) + fullRecommends := fullGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + localRecommends := localGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + if !reflect.DeepEqual(fullRecommends, localRecommends) { + t.Fatalf("expected full/local config measurement suffixes to match, full=%#v local=%#v", fullRecommends, localRecommends) + } +} + +func TestNormalizeRecommendResultsKeepsFullAndLocalBayMeasurementSuffixesConsistent(t *testing.T) { + fullPrefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay" + localPrefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay" + members := []string{"IA_rms", "IB_rms", "IC_rms"} + + fullResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(fullPrefix+".", "."), members), + IsFuzzy: false, + }, + } + localResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(localPrefix+".", "."), members), + IsFuzzy: false, + }, + } + + fullGot := normalizeRecommendResults(fullPrefix+".", fullResults) + localGot := normalizeRecommendResults(localPrefix+".", localResults) + fullRecommends := fullGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + localRecommends := localGot[constants.MeasTagRecommendHierarchyType.String()].QueryDatas + if !reflect.DeepEqual(fullRecommends, localRecommends) { + t.Fatalf("expected full/local bay measurement suffixes to match, full=%#v local=%#v", fullRecommends, localRecommends) + } +} + func TestRecommendGroupTokens(t *testing.T) { tests := []struct { name string @@ -259,6 +392,242 @@ func TestRecommendGroupTokens(t *testing.T) { } } +func TestConfigRecommendCompTag(t *testing.T) { + tests := []struct { + name string + inputSlice []string + want string + wantOK bool + }{ + { + name: "full token1 to token6 input", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "b"}, + want: "comp_tag", + wantOK: true, + }, + { + name: "local token4 to token6 input", + inputSlice: []string{"nspath", "comp_tag", "b"}, + want: "comp_tag", + wantOK: true, + }, + { + name: "missing comp tag", + inputSlice: []string{"nspath"}, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotOK := configRecommendCompTag(tt.inputSlice) + if gotOK != tt.wantOK { + t.Fatalf("expected ok %t, got %t", tt.wantOK, gotOK) + } + if got != tt.want { + t.Fatalf("expected compTag %q, got %q", tt.want, got) + } + }) + } +} + +func TestFilterMembersByTrimmedPrefix(t *testing.T) { + tests := []struct { + name string + members []string + searchInput string + wantMembers []string + wantMatchInput string + }{ + { + name: "config typo falls back to previous rune", + members: []string{"bay", "base_extend", "model", "rated", "stable", "component"}, + searchInput: "bx", + wantMembers: []string{"bay", "base_extend"}, + wantMatchInput: "b", + }, + { + name: "measurement typo falls back to previous rune", + members: []string{"I_A_rms", "I_B_rms", "U_A_rms"}, + searchInput: "Ix", + wantMembers: []string{"I_A_rms", "I_B_rms"}, + wantMatchInput: "I", + }, + { + name: "no fallback to empty prefix", + members: []string{"bay", "base_extend"}, + searchInput: "x", + wantMembers: []string{}, + wantMatchInput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMembers, gotMatchInput := filterMembersByTrimmedPrefix(tt.members, tt.searchInput) + if !reflect.DeepEqual(gotMembers, tt.wantMembers) { + t.Fatalf("expected members %#v, got %#v", tt.wantMembers, gotMembers) + } + if gotMatchInput != tt.wantMatchInput { + t.Fatalf("expected matched input %q, got %q", tt.wantMatchInput, gotMatchInput) + } + }) + } +} + +func TestDotAndTypoInputsShareTrimmedPrefixFallback(t *testing.T) { + tests := []struct { + name string + dotInput string + typoInput string + members []string + wantSuffix []string + }{ + { + name: "token1 grid", + dotInput: "g.", + typoInput: "gx", + members: []string{"grid000", "grid001", "zone000"}, + wantSuffix: []string{"grid000", "grid001"}, + }, + { + name: "token2 zone", + dotInput: "z.", + typoInput: "zx", + members: []string{"zone000", "zone001", "station000"}, + wantSuffix: []string{"zone000", "zone001"}, + }, + { + name: "token3 station", + dotInput: "s.", + typoInput: "sx", + members: []string{"station000", "station001", "zone000"}, + wantSuffix: []string{"station000", "station001"}, + }, + { + name: "token4 nspath", + dotInput: "1.", + typoInput: "1x", + members: []string{"110kV_TV-demoProject", "110kV_TV-testProject1", "220kV_TV-demoProject"}, + wantSuffix: []string{"110kV_TV-demoProject", "110kV_TV-testProject1"}, + }, + { + name: "token5 component tag", + dotInput: "c.", + typoInput: "cx", + members: []string{"cable_22-testProject1110kV_TV", "cable_26-demoProject110kV_TV", "bay"}, + wantSuffix: []string{"cable_22-testProject1110kV_TV", "cable_26-demoProject110kV_TV"}, + }, + { + name: "token6 config", + dotInput: "b.", + typoInput: "bx", + members: []string{"bay", "base_extend", "component"}, + wantSuffix: []string{"bay", "base_extend"}, + }, + { + name: "token7 measurement", + dotInput: "I.", + typoInput: "Ix", + members: []string{"IA_rms", "IB_rms", "UA_rms"}, + wantSuffix: []string{"IA_rms", "IB_rms"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dotSearchInput := strings.TrimSuffix(tt.dotInput, ".") + typoSearchInput := trimLastRune(tt.typoInput) + dotMembers, dotMatchedInput := filterMembersByTrimmedPrefix(tt.members, dotSearchInput) + typoMembers, typoMatchedInput := filterMembersByTrimmedPrefix(tt.members, typoSearchInput) + + if dotMatchedInput != typoMatchedInput { + t.Fatalf("expected dot and typo matched input to be equal, dot=%q typo=%q", dotMatchedInput, typoMatchedInput) + } + if !reflect.DeepEqual(dotMembers, typoMembers) { + t.Fatalf("expected dot and typo members to be equal, dot=%#v typo=%#v", dotMembers, typoMembers) + } + if !reflect.DeepEqual(dotMembers, tt.wantSuffix) { + t.Fatalf("expected members %#v, got %#v", tt.wantSuffix, dotMembers) + } + }) + } +} + +func TestExactMatchChecksForInput(t *testing.T) { + tests := []struct { + name string + inputSlice []string + want []exactMatchCheck + }{ + { + name: "token1 can be grid or local nspath", + inputSlice: []string{"g"}, + want: []exactMatchCheck{ + {setKey: constants.RedisAllGridSetKey, member: "g"}, + {setKey: constants.RedisAllCompNSPathSetKey, member: "g"}, + }, + }, + { + name: "token1 token2 can be zone comp tag or nspath meas", + inputSlice: []string{"grid000", "z"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecGridZoneSetKey, "grid000"), member: "z"}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "grid000"), member: "z"}, + {setKey: constants.RedisAllCompTagSetKey, member: "z"}, + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, "grid000"), member: "z"}, + }, + }, + { + name: "token1 token2 token3 can be station or local config", + inputSlice: []string{"grid000", "zone000", "s"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecZoneStationSetKey, "zone000"), member: "s"}, + {setKey: constants.RedisAllConfigSetKey, member: "s"}, + }, + }, + { + name: "token1 token2 token3 token4 can be nspath or local meas", + inputSlice: []string{"grid000", "zone000", "station000", "1"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, "station000"), member: "1"}, + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "zone000"), member: "1"}, + }, + }, + { + name: "token1 through token5 component tag", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "c"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, "nspath"), member: "c"}, + {setKey: constants.RedisAllCompTagSetKey, member: "c"}, + }, + }, + { + name: "token1 through token6 config", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "b"}, + want: []exactMatchCheck{ + {setKey: constants.RedisAllConfigSetKey, member: "b"}, + }, + }, + { + name: "token1 through token7 measurement", + inputSlice: []string{"grid000", "zone000", "station000", "nspath", "comp_tag", "bay", "I"}, + want: []exactMatchCheck{ + {setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"), member: "I"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := exactMatchChecksForInput(tt.inputSlice) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expected checks %#v, got %#v", tt.want, got) + } + }) + } +} + func TestFallbackSpecificSetKey(t *testing.T) { tests := []struct { name string From a5e8c2ba4e3e93cac83e31a1707d784806536259 Mon Sep 17 00:00:00 2001 From: douxu Date: Fri, 10 Jul 2026 16:03:16 +0800 Subject: [PATCH 12/14] fix: improve redis recommendation fuzzy fallback handling - Preserve level continuation marker when trimming recommendation terms - Add group fallback for config and measurement typo inputs - Validate fuzzy exact continuation with redis set membership - Use local nspath set for local fuzzy member checks - Avoid returning continuation marker for exact measurement tag matches - Add tests for fuzzy continuation and mismatch fallback behavior --- model/redis_recommend.go | 94 ++++++++++++++++++++-- model/redis_recommend_test.go | 144 ++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 8 deletions(-) diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 7483207..d91b870 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -418,6 +418,15 @@ func trimRecommendTerms(recommends []string, offset int) []string { seen := make(map[string]struct{}) for _, recommend := range recommends { + if recommend == "." { + if _, exists := seen[recommend]; exists { + continue + } + seen[recommend] = struct{}{} + resultRecommends = append(resultRecommends, recommend) + continue + } + recommendRunes := []rune(recommend) if offset > len(recommendRunes) { continue @@ -530,7 +539,7 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str searchInput := inputSlice[len(inputSlice)-1] matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) if len(matchedMembers) == 0 { - return []string{}, 0, nil + return fallbackConfigGroupByInput(ctx, rdb, recommendLenType, inputSlice, members) } prefixTokens := inputSlice[:len(inputSlice)-1] @@ -542,6 +551,16 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil } +func fallbackConfigGroupByInput(ctx context.Context, rdb *redis.Client, recommendLenType string, inputSlice []string, members []string) ([]string, int, error) { + recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, members) + recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + + return recommends, recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil +} + func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey string, recommendLenType string, inputSlice []string) ([]string, int, error) { members, err := rdb.SMembers(ctx, allSetKey).Result() if err != nil && err != redis.Nil { @@ -551,7 +570,7 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin searchInput := inputSlice[len(inputSlice)-1] matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) if len(matchedMembers) == 0 { - return []string{}, 0, nil + return fallbackMeasGroupByInput(ctx, rdb, recommendLenType, inputSlice) } recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) @@ -563,6 +582,27 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil } +func fallbackMeasGroupByInput(ctx context.Context, rdb *redis.Client, recommendLenType string, inputSlice []string) ([]string, int, error) { + compTag, _, ok := recommendGroupTokens(inputSlice) + if !ok { + return []string{}, 0, nil + } + + queryKey := getSpecificKeyByLength(constants.MeasTagRecommendHierarchyType, compTag) + members, err := rdb.SMembers(ctx, queryKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + + recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, members) + recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommends) + if len(recommends) == 0 { + return []string{}, 0, nil + } + + return recommends, recommendPrefixOffset(inputSlice[:len(inputSlice)-1]), nil +} + func filterMembersByTrimmedPrefix(members []string, searchInput string) ([]string, string) { for len([]rune(searchInput)) != 0 { filtered := make([]string, 0, len(members)) @@ -1060,7 +1100,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co if keyExists { var QueryData []string - if hierarchy == constants.MaxIdentifyHierarchy || (hierarchy == constants.IdentifyHierarchy && redisSetKey == constants.RedisAllMeasTagSetKey) { + if hierarchy == constants.MeasTagRecommendHierarchyType { QueryData = []string{""} } else { QueryData = []string{"."} @@ -1148,6 +1188,10 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, return nil, 0, fmt.Errorf("redisearch suggest failed: %w", err) } if len(results) == 0 { + if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { + return []string{"."}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + } + // 如果没有结果,退一步(删除最后一个字符)并继续循环 // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 searchInput = trimLastRune(searchInput) @@ -1187,7 +1231,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, memberComparePrefix, termLastPart) + exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, termLastPart) if err != nil || !exists { logger.Info(ctx, "fuzzy recommend term not found in specific redis set", "hierarchy", hierarchy, @@ -1201,6 +1245,11 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, continue } + if termLastPart == searchInput { + recommends = append(recommends, fuzzyExactContinuationValue(hierarchy)) + continue + } + recommend := result.Term if hierarchy == constants.MeasTagRecommendHierarchyType && configToken != "" && termPrefix == memberComparePrefix { recommend = strings.Join([]string{termPrefix, configToken, termLastPart}, ".") @@ -1209,16 +1258,42 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } } if len(recommends) == 0 { + if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { + return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + } + searchInput = trimLastRune(searchInput) searchInputLen = len([]rune(searchInput)) continue } - return recommends, fuzzyRecommendOffset(searchPrefix, searchInput), nil + offset := fuzzyRecommendOffset(searchPrefix, searchInput) + return recommends, offset, nil } return []string{}, 0, nil } +func fuzzyExactContinuationExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, memberComparePrefix string, searchInput string, fuzzyInput string) bool { + exists, err := fuzzyRecommendMemberExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput) + if err != nil { + logger.Info(ctx, "fuzzy exact continuation check failed", + "hierarchy", hierarchy, + "recommend_len_type", recommendLenType, + "query_key", fuzzyInput, + "search_input", searchInput, + "error", err, + ) + } + return exists +} + +func fuzzyExactContinuationValue(hierarchy constants.RecommendHierarchyType) string { + if hierarchy == constants.MeasTagRecommendHierarchyType { + return "" + } + return "." +} + // fuzzyRecommendOffset 返回模糊命中的裁剪位置, 保留完整输入前缀参与 offset。 func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { if searchPrefix == "" { @@ -1228,8 +1303,8 @@ func fuzzyRecommendOffset(searchPrefix string, searchInput string) int { } // fuzzyRecommendMemberExists 校验模糊候选末级 token 是否存在于对应 Redis set。 -func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, comparePrefix string, termLastPart string) (bool, error) { - setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, comparePrefix) +func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string, termLastPart string) (bool, error) { + setKey, ok := fuzzyRecommendMemberSetKey(hierarchy, recommendLenType, comparePrefix) if !ok { return true, nil } @@ -1237,7 +1312,7 @@ func fuzzyRecommendMemberExists(ctx context.Context, rdb *redis.Client, hierarch } // fuzzyRecommendMemberSetKey 返回模糊候选校验时应使用的 Redis set key。 -func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comparePrefix string) (string, bool) { +func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, recommendLenType string, comparePrefix string) (string, bool) { switch hierarchy { case constants.GridRecommendHierarchyType: return constants.RedisAllGridSetKey, true @@ -1246,6 +1321,9 @@ func fuzzyRecommendMemberSetKey(hierarchy constants.RecommendHierarchyType, comp case constants.StationRecommendHierarchyType: return fmt.Sprintf(constants.RedisSpecZoneStationSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" case constants.CompNSPathRecommendHierarchyType: + if recommendLenType == constants.IsLocalRecommendLength { + return constants.RedisAllCompNSPathSetKey, true + } return fmt.Sprintf(constants.RedisSpecStationCompNSPATHSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" case constants.CompTagRecommendHierarchyType: return fmt.Sprintf(constants.RedisSpecCompNSPathCompTagSetKey, lastRecommendPrefixToken(comparePrefix)), comparePrefix != "" diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index a522789..c386a10 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -46,6 +46,78 @@ func TestFuzzyRecommendOffsetUsesFullMatchedInput(t *testing.T) { } } +func TestFuzzyRecommendMemberSetKeyUsesLocalNSPathSet(t *testing.T) { + setKey, ok := fuzzyRecommendMemberSetKey(constants.CompNSPathRecommendHierarchyType, constants.IsLocalRecommendLength, "") + if !ok { + t.Fatalf("expected local nspath fuzzy member check to use a redis set") + } + if setKey != constants.RedisAllCompNSPathSetKey { + t.Fatalf("expected set key %q, got %q", constants.RedisAllCompNSPathSetKey, setKey) + } +} + +func TestNormalizeRecommendResultsKeepsFuzzyExactContinuation(t *testing.T) { + tests := []struct { + name string + input string + recommendType constants.RecommendHierarchyType + offset int + }{ + { + name: "local nspath typo completes current level", + input: "110kV_TV-demoProjectx", + recommendType: constants.CompNSPathRecommendHierarchyType, + offset: len([]rune("110kV_TV-demoProject")), + }, + { + name: "grid typo completes current level", + input: "grid000x", + recommendType: constants.GridRecommendHierarchyType, + offset: len([]rune("grid000")), + }, + { + name: "zone typo completes current level", + input: "grid000.zone000x", + recommendType: constants.ZoneRecommendHierarchyType, + offset: len([]rune("grid000.zone000")), + }, + { + name: "station typo completes current level", + input: "grid000.zone000.station000x", + recommendType: constants.StationRecommendHierarchyType, + offset: len([]rune("grid000.zone000.station000")), + }, + { + name: "full nspath typo completes current level", + input: "grid000.zone000.station000.110kV_TV-demoProjectx", + recommendType: constants.CompNSPathRecommendHierarchyType, + offset: len([]rune("grid000.zone000.station000.110kV_TV-demoProject")), + }, + } + + 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: true, + Offset: tt.offset, + }, + } + + got := normalizeRecommendResults(tt.input, results) + result := got[tt.recommendType.String()] + if result.Offset != tt.offset { + t.Fatalf("expected offset %d, got %d", tt.offset, result.Offset) + } + if !reflect.DeepEqual(result.QueryDatas, []string{"."}) { + t.Fatalf("expected fuzzy exact continuation '.', got %#v", result.QueryDatas) + } + }) + } +} + func TestNormalizeRecommendResultsUsesInputLengthForExactCompletion(t *testing.T) { tests := []struct { name string @@ -350,6 +422,78 @@ func TestNormalizeRecommendResultsKeepsFullAndLocalBayMeasurementSuffixesConsist } } +func TestNormalizeRecommendResultsKeepsConfigMismatchFallbackConsistentWithEmptyInput(t *testing.T) { + prefix := "110kV_TV-demoProject.cable_22-testProject1110kV_TV" + members := []string{"bay", "base_extend", "model", "component"} + groupResults := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, constants.IsLocalRecommendLength, strings.Split(prefix+".", "."), members) + offset := recommendPrefixOffset(strings.Split(prefix, ".")) + + emptyInputResults := map[string]SearchResult{ + constants.ConfigRecommendHierarchyType.String(): { + RecommendType: constants.ConfigRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: false, + }, + } + mismatchFallbackResults := map[string]SearchResult{ + constants.ConfigRecommendHierarchyType.String(): { + RecommendType: constants.ConfigRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: true, + IsFallback: true, + Offset: offset, + }, + } + + emptyGot := normalizeRecommendResults(prefix+".", emptyInputResults) + mismatchGot := normalizeRecommendResults(prefix+".x", mismatchFallbackResults) + emptyResult := emptyGot[constants.ConfigRecommendHierarchyType.String()] + mismatchResult := mismatchGot[constants.ConfigRecommendHierarchyType.String()] + + if mismatchResult.Offset != emptyResult.Offset { + t.Fatalf("expected config mismatch fallback offset %d, got %d", emptyResult.Offset, mismatchResult.Offset) + } + if !reflect.DeepEqual(mismatchResult.QueryDatas, emptyResult.QueryDatas) { + t.Fatalf("expected config mismatch fallback recommends to match empty input, empty=%#v mismatch=%#v", emptyResult.QueryDatas, mismatchResult.QueryDatas) + } +} + +func TestNormalizeRecommendResultsKeepsMeasurementMismatchFallbackConsistentWithEmptyInput(t *testing.T) { + prefix := "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay" + members := []string{"IA_rms_CTA-testProject1", "IB_rms_CTA-testProject1", "IC_rms_CTA-testProject1"} + groupResults := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, constants.FullRecommendLength, strings.Split(prefix+".", "."), members) + offset := recommendPrefixOffset(strings.Split(prefix, ".")) + + emptyInputResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: false, + }, + } + mismatchFallbackResults := map[string]SearchResult{ + constants.MeasTagRecommendHierarchyType.String(): { + RecommendType: constants.MeasTagRecommendHierarchyType, + QueryDatas: append([]string{}, groupResults...), + IsFuzzy: true, + IsFallback: true, + Offset: offset, + }, + } + + emptyGot := normalizeRecommendResults(prefix+".", emptyInputResults) + mismatchGot := normalizeRecommendResults(prefix+".x", mismatchFallbackResults) + emptyResult := emptyGot[constants.MeasTagRecommendHierarchyType.String()] + mismatchResult := mismatchGot[constants.MeasTagRecommendHierarchyType.String()] + + if mismatchResult.Offset != emptyResult.Offset { + t.Fatalf("expected measurement mismatch fallback offset %d, got %d", emptyResult.Offset, mismatchResult.Offset) + } + if !reflect.DeepEqual(mismatchResult.QueryDatas, emptyResult.QueryDatas) { + t.Fatalf("expected measurement mismatch fallback recommends to match empty input, empty=%#v mismatch=%#v", emptyResult.QueryDatas, mismatchResult.QueryDatas) + } +} + func TestRecommendGroupTokens(t *testing.T) { tests := []struct { name string From 78f68b9c4fb0fcdd44c0f318bfa7474f5b076bec Mon Sep 17 00:00:00 2001 From: douxu Date: Fri, 10 Jul 2026 17:31:56 +0800 Subject: [PATCH 13/14] fix: fallback to current recommend group after hash filtering - Return current config group recommendations when matched config candidates fail hash validation - Return current measurement group recommendations when matched tag candidates fail hash validation --- model/redis_recommend.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/model/redis_recommend.go b/model/redis_recommend.go index d91b870..0fbc100 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -546,7 +546,7 @@ func fallbackConfigByInput(ctx context.Context, rdb *redis.Client, allSetKey str recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) if len(recommends) == 0 { - return []string{}, 0, nil + return fallbackConfigGroupByInput(ctx, rdb, recommendLenType, inputSlice, members) } return recommends, fuzzyRecommendOffset(strings.Join(prefixTokens, "."), matchedInput), nil } @@ -576,7 +576,7 @@ func fallbackMeasByInput(ctx context.Context, rdb *redis.Client, allSetKey strin recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) if len(recommends) == 0 { - return []string{}, 0, nil + return fallbackMeasGroupByInput(ctx, rdb, recommendLenType, inputSlice) } return recommends, fuzzyRecommendOffset(strings.Join(inputSlice[:len(inputSlice)-1], "."), matchedInput), nil From 8195200cea4cdf00a8895a69b955fa1cb60660c3 Mon Sep 17 00:00:00 2001 From: douxu Date: Mon, 13 Jul 2026 15:16:30 +0800 Subject: [PATCH 14/14] fix(recommend): handle trailing dots on complete measurements - normalize redundant trailing dots for supported measurement formats - validate exact measurement matches against component-specific Redis sets - prevent terminal measurement matches from returning dot continuations - add regression tests for offsets and exact completions --- model/redis_recommend.go | 59 +++++++++++++++++++++-- model/redis_recommend_test.go | 91 +++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/model/redis_recommend.go b/model/redis_recommend.go index 0fbc100..6c2aa63 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -215,7 +215,7 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe return handleLevelFuzzySearch(ctx, rdb, constants.CompNSPathRecommendHierarchyType, constants.FullRecommendLength, constants.RedisAllCompNSPathSetKey, inputSlice) }, func() SearchResult { - return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllConfigSetKey, inputSlice) + return handleLevelFuzzySearch(ctx, rdb, constants.MeasTagRecommendHierarchyType, constants.IsLocalRecommendLength, constants.RedisAllMeasTagSetKey, inputSlice) }, ) return finalizeResults(searchInput, results) @@ -261,7 +261,21 @@ func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, inp return input } - checks := exactMatchChecksForInput(strings.Split(trimmedInput, ".")) + inputSlice := strings.Split(trimmedInput, ".") + if check, ok := completeMeasurementExactMatchCheck(inputSlice); ok { + exists, err := rdb.SIsMember(ctx, check.setKey, check.member).Result() + if err != nil { + logger.Warn(ctx, "check complete measurement with trailing dot failed", "input", input, "key", check.setKey, "member", check.member, "error", err) + return input + } + if exists { + // A measurement is terminal. Its trailing dot cannot enter another + // hierarchy level, so search the complete measurement without it. + return trimmedInput + } + } + + checks := exactMatchChecksForInput(inputSlice) if len(checks) == 0 { return input } @@ -279,6 +293,30 @@ func normalizeTrailingDotSearchInput(ctx context.Context, rdb *redis.Client, inp return trimmedInput } +// completeMeasurementExactMatchCheck returns the authoritative Redis lookup +// for each supported complete measurement identity shape. +func completeMeasurementExactMatchCheck(inputSlice []string) (exactMatchCheck, bool) { + switch len(inputSlice) { + case 7: // token1.token2.token3.token4.token5.token6.token7 + return exactMatchCheck{ + setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[4]), + member: inputSlice[6], + }, true + case 4: // token4.token5.token6.token7 + return exactMatchCheck{ + setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, inputSlice[1]), + member: inputSlice[3], + }, true + case 2: // token4.token7 + return exactMatchCheck{ + setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, inputSlice[0]), + member: inputSlice[1], + }, true + default: + return exactMatchCheck{}, false + } +} + type exactMatchCheck struct { setKey string member string @@ -1087,9 +1125,10 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } } - keyExists, err := rdb.SIsMember(ctx, redisSetKey, searchInput).Result() + exactSetKey := exactSearchRedisSetKey(hierarchy, inputSlice, redisSetKey) + keyExists, err := rdb.SIsMember(ctx, exactSetKey, searchInput).Result() if err != nil { - logger.Error(ctx, "check key exist from redis set failed ", "key", redisSetKey, "error", err) + logger.Error(ctx, "check key exist from redis set failed ", "key", exactSetKey, "error", err) return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, @@ -1153,6 +1192,16 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co } } +// exactSearchRedisSetKey uses the component-specific measurement set for +// token7, ensuring an exact hit belongs to the component in the input path. +func exactSearchRedisSetKey(hierarchy constants.RecommendHierarchyType, inputSlice []string, defaultSetKey string) string { + if hierarchy == constants.MeasTagRecommendHierarchyType && len(inputSlice) >= 3 { + compTag := inputSlice[len(inputSlice)-3] + return fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag) + } + return defaultSetKey +} + // runFuzzySearch 执行 RediSearch 模糊搜索, 并用 Redis set 校验候选项确属当前层级。 func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) { var configToken string @@ -1189,7 +1238,7 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, } if len(results) == 0 { if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { - return []string{"."}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil } // 如果没有结果,退一步(删除最后一个字符)并继续循环 diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go index c386a10..c559b4d 100644 --- a/model/redis_recommend_test.go +++ b/model/redis_recommend_test.go @@ -56,6 +56,43 @@ func TestFuzzyRecommendMemberSetKeyUsesLocalNSPathSet(t *testing.T) { } } +func TestMeasurementExactSearchUsesComponentSpecificSet(t *testing.T) { + tests := []struct { + name string + inputSlice []string + want string + }{ + { + name: "token1 through token7", + inputSlice: []string{"grid", "zone", "station", "nspath", "comp_tag", "bay", "measurement"}, + want: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"), + }, + { + name: "token4 through token7", + inputSlice: []string{"nspath", "comp_tag", "bay", "measurement"}, + want: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "comp_tag"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := exactSearchRedisSetKey(constants.MeasTagRecommendHierarchyType, tt.inputSlice, constants.RedisAllMeasTagSetKey) + if got != tt.want { + t.Fatalf("expected exact search set %q, got %q", tt.want, got) + } + }) + } +} + +func TestFuzzyExactContinuationValueTreatsMeasurementAsTerminal(t *testing.T) { + if got := fuzzyExactContinuationValue(constants.MeasTagRecommendHierarchyType); got != "" { + t.Fatalf("expected empty completion for measurement, got %q", got) + } + if got := fuzzyExactContinuationValue(constants.CompNSPathRecommendHierarchyType); got != "." { + t.Fatalf("expected level continuation for component nspath, got %q", got) + } +} + func TestNormalizeRecommendResultsKeepsFuzzyExactContinuation(t *testing.T) { tests := []struct { name string @@ -772,6 +809,60 @@ func TestExactMatchChecksForInput(t *testing.T) { } } +func TestCompleteMeasurementTrailingDotChecks(t *testing.T) { + tests := []struct { + name string + input string + wantOffset int + wantCheck exactMatchCheck + }{ + { + name: "token1 through token7", + input: "grid000.zone000.station000.110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay.IA_rms_CTA-testProject1.", + wantOffset: 105, + wantCheck: exactMatchCheck{ + setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "cable_22-testProject1110kV_TV"), + member: "IA_rms_CTA-testProject1", + }, + }, + { + name: "token4 through token7", + input: "110kV_TV-demoProject.cable_22-testProject1110kV_TV.bay.IA_rms_CTA-testProject1.", + wantOffset: 78, + wantCheck: exactMatchCheck{ + setKey: fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, "cable_22-testProject1110kV_TV"), + member: "IA_rms_CTA-testProject1", + }, + }, + { + name: "token4 and token7", + input: "110kV_TV-testProject1.IA_rms_CTA-testProject1.", + wantOffset: 45, + wantCheck: exactMatchCheck{ + setKey: fmt.Sprintf(constants.RedisSpecCompNSPathMeasSetKey, "110kV_TV-testProject1"), + member: "IA_rms_CTA-testProject1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + measurement := strings.TrimSuffix(tt.input, ".") + if got := len([]rune(measurement)); got != tt.wantOffset { + t.Fatalf("expected offset %d, got %d", tt.wantOffset, got) + } + + got, ok := completeMeasurementExactMatchCheck(strings.Split(measurement, ".")) + if !ok { + t.Fatal("expected complete measurement exact-match check") + } + if !reflect.DeepEqual(got, tt.wantCheck) { + t.Fatalf("expected check %#v, got %#v", tt.wantCheck, got) + } + }) + } +} + func TestFallbackSpecificSetKey(t *testing.T) { tests := []struct { name string