diff --git a/constants/recommend_keys.go b/constants/recommend_keys.go index 2fde23f..de2e443 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 ( @@ -42,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_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/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/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/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/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/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 ef79916..40ce7bd 100644 --- a/handler/measurement_recommend.go +++ b/handler/measurement_recommend.go @@ -2,11 +2,13 @@ package handler import ( + "errors" + "strings" + + "modelRT/constants" "modelRT/logger" "modelRT/model" "modelRT/network" - "modelRT/util" - "net/http" "github.com/gin-gonic/gin" ) @@ -43,79 +45,81 @@ 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) - c.JSON(http.StatusOK, network.FailureResponse{ - Code: http.StatusBadRequest, - Msg: err.Error(), + logger.Error(ctx, "failed to bind measurement recommend request", "error", err) + 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 } + recommendResults := model.RedisSearchRecommend(ctx, request.Input) + payload := network.MeasurementRecommendPayload{ + Input: request.Input, + RecommendedList: make([]string, 0), + } + seen := make(map[string]struct{}) + orderedResults := orderedRecommendResults(recommendResults) - recommendResults := model.RedisSearchRecommend(c, request.Input) - payloads := make([]network.MeasurementRecommendPayload, 0, len(recommendResults)) - for _, recommendResult := range 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) - c.JSON(http.StatusOK, network.FailureResponse{ - Code: http.StatusInternalServerError, - Msg: err.Error(), - Payload: map[string]any{ - "input": request.Input, - }, + logger.Error(ctx, "failed to get recommend data from redis", "input", request.Input, "error", err) + renderRespFailure(c, constants.RespCodeServerError, err.Error(), map[string]any{ + "input": request.Input, }) 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) - } - } - } - - 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, - }) + for _, recommendResult := range orderedResults { + for _, recommend := range recommendResult.QueryDatas { + if _, exists := seen[recommend]; !exists { + seen[recommend] = struct{}{} + payload.RecommendedList = append(payload.RecommendedList, recommend) + } + } + } + + renderRespSuccess(c, constants.RespCodeSuccess, "success", 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 +} + +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/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/main.go b/main.go index 8701c63..b9c38e3 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/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/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/component_column_recommend_model.go b/model/component_column_recommend_model.go new file mode 100644 index 0000000..1d94272 --- /dev/null +++ b/model/component_column_recommend_model.go @@ -0,0 +1,91 @@ +// 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...) + pipe.HSet(ctx, componentColumnGroupKey(compTag), componentColumnHashFields(componentColumnNames)...) + + 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 +} + +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 02babc8..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 @@ -184,10 +191,25 @@ 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) - ac.AddTerms(sug...) + if len(measTags) > 0 { + pipe.HSet(ctx, recommendGroupKey(compTag, "bay"), recommendHashFields(measTags)...) + } + safeAddTerms(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) + safeAddTerms(sug) } - var allErrs []error cmders, execErr := pipe.Exec(ctx) if execErr != nil { logger.Error(ctx, "pipeline execution failed", "error", execErr) @@ -211,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/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/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/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 ddfef2a..6c2aa63 100644 --- a/model/redis_recommend.go +++ b/model/redis_recommend.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "math" + "slices" "strings" + "sync" "modelRT/constants" "modelRT/diagram" @@ -16,6 +18,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 @@ -23,7 +26,9 @@ 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 } @@ -34,11 +39,44 @@ func InitAutocompleterWithPool(pool *redigo.Pool) { ac = redisearch.NewAutocompleterFromPool(pool, constants.RedisSearchDictName) } -func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, recommendLenType string, searchInput string, searchRedisKey string, fanInChan chan SearchResult) { +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) (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, @@ -51,53 +89,64 @@ 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, 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{ + 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 } - fanInChan <- SearchResult{ + 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) + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + IsFallback: true, + Err: err, + } + } + + return SearchResult{ RecommendType: hierarchy, - QueryDatas: []string{}, + QueryDatas: recommends, IsFuzzy: true, + IsFallback: true, + Offset: 0, Err: nil, } } @@ -105,214 +154,575 @@ 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() - - 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 { - 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 - } - results[result.RecommendType.String()] = result - } - - return results + finalizeResults := func(normalizeInput string, results map[string]SearchResult) map[string]SearchResult { + results = suppressFallbackResultsWhenConcreteHitExists(results) + return normalizeRecommendResults(normalizeInput, results) } - inputSlice := strings.Split(input, ".") - inputSliceLen := len(inputSlice) + 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) + } - fanInChan := make(chan SearchResult, 4) + searchInput := normalizeTrailingDotSearchInput(ctx, rdb, input) + inputSlice := strings.Split(searchInput, ".") + inputSliceLen := len(inputSlice) 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 { - 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 - } - results[result.RecommendType.String()] = result - } - - return 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(searchInput, 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) - 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 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(searchInput, 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 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(searchInput, 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 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.RedisAllMeasTagSetKey, inputSlice) + }, + ) + return finalizeResults(searchInput, 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 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(searchInput, 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 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(searchInput, 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 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(searchInput, results) default: logger.Error(ctx, "unsupport length of search input", "input_len", inputSliceLen) return nil } } +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 + } + + 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 + } + + 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 +} + +// 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 +} + +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 + 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 +} + +// normalizeRecommendResults 统一处理 offset、去重和裁剪, 让响应只返回用户还没输入的部分。 +func normalizeRecommendResults(input string, results map[string]SearchResult) map[string]SearchResult { + for key, result := range results { + if result.Err != nil { + continue + } + if !result.IsFuzzy { + 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 + } + 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 + for index, recommend := range recommends { + offset := util.GetLongestCommonPrefixLength(input, recommend) + if index == 0 || offset < minOffset { + minOffset = offset + } + } + return minOffset +} + +// recommendPrefixOffset 返回推荐结果中已由输入确定的前缀长度, 包含末尾分隔符。 +func recommendPrefixOffset(prefixTokens []string) int { + prefix := strings.Join(prefixTokens, ".") + if prefix == "" { + return 0 + } + return len([]rune(prefix)) + 1 +} + +// trimRecommendTerms 按 offset 截掉已输入前缀, 并去掉空值和重复值。 +func trimRecommendTerms(recommends []string, offset int) []string { + resultRecommends := make([]string, 0, len(recommends)) + 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 + } + + recommendTerm := string(recommendRunes[offset:]) + if recommendTerm == "" { + continue + } + if _, exists := seen[recommendTerm]; exists { + continue + } + + seen[recommendTerm] = struct{}{} + resultRecommends = append(resultRecommends, recommendTerm) + } + return resultRecommends +} + +// filterLocalNSPathResults 只保留本地组件 nspath, 避免远端 nspath 进入本地简写推荐。 +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 +} + +// 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() } -func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { +// fallbackAllCurrentLevel 兜底返回当前层级的全量集合。 +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 +} + +// fallbackCurrentLevelByInput 兜底时先校验上下级关系, 避免错误前缀返回全层级数据。 +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, 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 { + 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, 0, err + } + if count == 0 { + return []string{}, 0, nil + } + + members, err := rdb.SMembers(ctx, specificSetKey).Result() + if err != nil && err != redis.Nil { + return nil, 0, err + } + 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 fallbackConfigGroupByInput(ctx, rdb, recommendLenType, inputSlice, members) + } + + prefixTokens := inputSlice[:len(inputSlice)-1] + recommends := combineQueryResultByInput(constants.ConfigRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) + recommends = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) + if len(recommends) == 0 { + return fallbackConfigGroupByInput(ctx, rdb, recommendLenType, inputSlice, members) + } + 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 { + return nil, 0, err + } + + searchInput := inputSlice[len(inputSlice)-1] + matchedMembers, matchedInput := filterMembersByTrimmedPrefix(members, searchInput) + if len(matchedMembers) == 0 { + return fallbackMeasGroupByInput(ctx, rdb, recommendLenType, inputSlice) + } + + recommends := combineQueryResultByInput(constants.MeasTagRecommendHierarchyType, recommendLenType, inputSlice, matchedMembers) + recommends = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, matchedMembers, recommends) + if len(recommends) == 0 { + return fallbackMeasGroupByInput(ctx, rdb, recommendLenType, inputSlice) + } + + 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)) + 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。 +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: + 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 + } +} + +// getAllKeyByGridLevel 返回所有 grid tag, 用于空输入时的初始推荐。 +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, @@ -320,23 +730,23 @@ func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan } } -func getAllKeyByNSPathLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) { +// getAllKeyByNSPathLevel 返回所有组件 nspath, 用于空输入时的初始推荐。 +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, @@ -344,6 +754,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)) @@ -388,6 +799,82 @@ 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 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 + 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 { case constants.GridRecommendHierarchyType: @@ -409,8 +896,169 @@ func getSpecificKeyByLength(hierarchy constants.RecommendHierarchyType, keyPrefi } } -// 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) { +// handleNSPathMeasSearch 处理 nspath.measurement 的简写搜索路径。 +func handleNSPathMeasSearch(ctx context.Context, rdb *redis.Client, inputSlice []string) 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) + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: false, + Err: err, + } + } + if !nsPathExists { + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: []string{}, + IsFuzzy: false, + Err: nil, + } + } + + 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) + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: false, + Err: err, + } + } + + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: combineNSPathMeasResults(nsPath, members), + IsFuzzy: false, + Err: nil, + } + } + + 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) + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: false, + Err: err, + } + } + if measTagExists { + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: []string{""}, + IsFuzzy: false, + Err: nil, + } + } + + recommends, offset, err := runNSPathMeasFuzzySearch(ctx, nsPath, searchInput) + if err != nil { + return SearchResult{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + 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, + } +} + +// combineNSPathMeasResults 把 nspath 与 measurement tag 拼成简写推荐结果。 +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 +} + +// runNSPathMeasFuzzySearch 对 nspath.measurement 简写做模糊搜索, 无结果时逐字回退。 +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 +} + +// splitLastToken 拆分最后一个点号后的 token, 返回前缀和末级 token。 +func splitLastToken(term string) (string, string) { + lastDotIndex := strings.LastIndex(term, ".") + if lastDotIndex == -1 { + return "", term + } + return term[:lastDotIndex], term[lastDotIndex+1:] +} + +// handleLevelFuzzySearch 处理第二层及以后层级的精确、模糊和兜底推荐。 +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] @@ -433,140 +1081,143 @@ 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) - if hierarchy == constants.ConfigRecommendHierarchyType || hierarchy == constants.MeasTagRecommendHierarchyType { - // 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 + if hierarchy == constants.MeasTagRecommendHierarchyType { + recommandResults = confirmMeasRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults) } - fanInChan <- SearchResult{ + if hierarchy == constants.ConfigRecommendHierarchyType { + recommandResults = confirmConfigRecommendResultsByHash(ctx, rdb, inputSlice, members, recommandResults) + } + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommandResults, IsFuzzy: false, Err: nil, } - return } - 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) - fanInChan <- SearchResult{ + logger.Error(ctx, "check key exist from redis set failed ", "key", exactSetKey, "error", err) + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } if keyExists { var QueryData []string - if hierarchy == constants.MaxIdentifyHierarchy || (hierarchy == constants.IdentifyHierarchy && redisSetKey == constants.RedisAllMeasTagSetKey) { + if hierarchy == constants.MeasTagRecommendHierarchyType { QueryData = []string{""} } else { QueryData = []string{"."} } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: QueryData, IsFuzzy: false, Err: nil, } - return } // 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{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: nil, IsFuzzy: false, Err: err, } - return } + isFallback := false if len(recommends) == 0 { logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err) + 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{ + RecommendType: hierarchy, + QueryDatas: nil, + IsFuzzy: true, + IsFallback: true, + Err: err, + } + } + isFallback = true } - fanInChan <- SearchResult{ + return SearchResult{ RecommendType: hierarchy, QueryDatas: recommends, IsFuzzy: true, + IsFallback: isFallback, + 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) { +// 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 var comparePrefix string - searchInputLen := len(searchInput) + 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] } } @@ -583,14 +1234,17 @@ 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 { - // 如果没有结果,退一步(删除最后一个字节)并继续循环 - // TODO 考虑使用其他方式代替 for 循环退一字节的查询方式 - searchInput = searchInput[:len(searchInput)-1] - searchInputLen = len(searchInput) + if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { + return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + } + + // 如果没有结果,退一步(删除最后一个字符)并继续循环 + // TODO 考虑使用其他方式代替 for 循环退一字符的查询方式 + searchInput = trimLastRune(searchInput) + searchInputLen = len([]rune(searchInput)) continue } @@ -610,21 +1264,14 @@ 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 - } - } - - 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 { @@ -633,15 +1280,124 @@ 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, memberComparePrefix, 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 + } + + if termLastPart == searchInput { + recommends = append(recommends, fuzzyExactContinuationValue(hierarchy)) + continue + } + 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) } } + if len(recommends) == 0 { + if fuzzyExactContinuationExists(ctx, rdb, hierarchy, recommendLenType, memberComparePrefix, searchInput, fuzzyInput) { + return []string{fuzzyExactContinuationValue(hierarchy)}, fuzzyRecommendOffset(searchPrefix, searchInput), nil + } - return recommends, nil + searchInput = trimLastRune(searchInput) + searchInputLen = len([]rune(searchInput)) + continue + } + + offset := fuzzyRecommendOffset(searchPrefix, searchInput) + return recommends, offset, nil } - return []string{}, 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 == "" { + 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: + 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 != "" + 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 { + return s + } + return string(runes[:len(runes)-1]) } diff --git a/model/redis_recommend_test.go b/model/redis_recommend_test.go new file mode 100644 index 0000000..c559b4d --- /dev/null +++ b/model/redis_recommend_test.go @@ -0,0 +1,945 @@ +package model + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "modelRT/constants" +) + +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) + } + }) + } +} + +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 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 + 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 + 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 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 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 + 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 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 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 + 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) + } + }) + } +} + +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) + } + }) + } +} 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\"]"` } 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 } 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 } } 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)