From 3e787b3b097a5f6626e81c41fe4d9148120f6eb3 Mon Sep 17 00:00:00 2001 From: Jin Hai Date: Mon, 13 Apr 2026 15:07:04 +0800 Subject: [PATCH] Go: update search (#14023) ### What problem does this PR solve? Update search ### Type of change - [x] New Feature (non-breaking change which adds functionality) Signed-off-by: Jin Hai --- internal/dao/search.go | 21 ++++++- internal/handler/search.go | 112 +++++++++++++++++++++++++++++++++++++ internal/router/router.go | 11 ++-- internal/service/search.go | 104 ++++++++++++++++++++++++++++++---- 4 files changed, 229 insertions(+), 19 deletions(-) diff --git a/internal/dao/search.go b/internal/dao/search.go index ea5c0ca49b..81ee5d52e5 100644 --- a/internal/dao/search.go +++ b/internal/dao/search.go @@ -155,8 +155,25 @@ func (dao *SearchDAO) DeleteByID(id string) error { // Accessible4Deletion checks if a search can be deleted by a specific user // Reference: Python search_service.py::accessible4deletion // Returns true if the search exists, is valid, and was created by the user -func (dao *SearchDAO) Accessible4Deletion(searchID string, userID string) bool { +func (dao *SearchDAO) Accessible4Deletion(searchID string, userID string) (bool, error) { var search entity.Search err := DB.Where("id = ? AND created_by = ? AND status = ?", searchID, userID, "1").First(&search).Error - return err == nil + return err == nil, err +} + +// GetByTenantIDAndID gets search by tenant ID and search ID +// Reference: Python SearchService.query(tenant_id=tenant_id, id=search_id) +func (dao *SearchDAO) GetByTenantIDAndID(tenantID string, searchID string) (*entity.Search, error) { + var search entity.Search + err := DB.Where("tenant_id = ? AND id = ? AND status = ?", tenantID, searchID, "1").First(&search).Error + if err != nil { + return nil, err + } + return &search, nil +} + +// UpdateByID updates search by ID +// Reference: Python common_service.py::update_by_id +func (dao *SearchDAO) UpdateByID(id string, updates map[string]interface{}) error { + return DB.Model(&entity.Search{}).Where("id = ?", id).Updates(updates).Error } diff --git a/internal/handler/search.go b/internal/handler/search.go index a1d31031eb..19d505a9c9 100644 --- a/internal/handler/search.go +++ b/internal/handler/search.go @@ -309,3 +309,115 @@ func (h *SearchHandler) DeleteSearch(c *gin.Context) { "message": "success", }) } + +// UpdateSearch update a search app +// @Summary Update Search App +// @Description Update a search app by ID +// @Tags search +// @Accept json +// @Produce json +// @Param search_id path string true "search app ID" +// @Param request body service.UpdateSearchRequest true "search update parameters" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/searches/{search_id} [put] +func (h *SearchHandler) UpdateSearch(c *gin.Context) { + // Get current user from context (same as Python current_user) + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + userID := user.ID + + // Get search_id from path parameter (same as Python ) + searchID := c.Param("search_id") + if searchID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": common.CodeBadRequest, + "data": nil, + "message": "search_id is required", + }) + return + } + + // Parse request body + var req service.UpdateSearchRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": common.CodeBadRequest, + "data": nil, + "message": "Invalid request body: " + err.Error(), + }) + return + } + + // Validate name (same as Python validation) + if err := common.ValidateName(req.Name); err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": err.Error(), + }) + return + } + + // Update search + updatedSearch, err := h.searchService.UpdateSearch(userID, searchID, &req) + if err != nil { + errMsg := err.Error() + switch errMsg { + case "no authorization": + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeAuthenticationError, + "data": false, + "message": "No authorization.", + }) + case "duplicated search name": + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": "Duplicated search name.", + }) + default: + // Check if it's a "cannot find search" error + if len(errMsg) > 18 && errMsg[:18] == "cannot find search" { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": false, + "message": errMsg, + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": errMsg, + }) + } + } + return + } + + // Convert to response format (same as Python updated_search.to_dict()) + result := map[string]interface{}{ + "id": updatedSearch.ID, + "tenant_id": updatedSearch.TenantID, + "name": updatedSearch.Name, + "description": updatedSearch.Description, + "created_by": updatedSearch.CreatedBy, + "status": updatedSearch.Status, + "create_time": updatedSearch.CreateTime, + "update_time": updatedSearch.UpdateTime, + "search_config": updatedSearch.SearchConfig, + } + + if updatedSearch.Avatar != nil { + result["avatar"] = *updatedSearch.Avatar + } + + // Return success response + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": result, + "message": "success", + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index c2c22855e8..e3e6a5d7f1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -175,17 +175,18 @@ func (r *Router) Setup(engine *gin.Engine) { // message.GET("/:memory_id/:message_id/content", r.memoryHandler.GetMessageContent) // } - chats := v1.Group("/chats") - { - chats.GET("", r.chatHandler.ListChats) - chats.GET("/:chat_id", r.chatHandler.GetChat) - } + chats := v1.Group("/chats") + { + chats.GET("", r.chatHandler.ListChats) + chats.GET("/:chat_id", r.chatHandler.GetChat) + } searches := v1.Group("/searches") { searches.GET("", r.searchHandler.ListSearches) searches.POST("", r.searchHandler.CreateSearch) searches.GET("/:search_id", r.searchHandler.GetSearch) + searches.PUT("/:search_id", r.searchHandler.UpdateSearch) searches.DELETE("/:search_id", r.searchHandler.DeleteSearch) } diff --git a/internal/service/search.go b/internal/service/search.go index 5d72e5a1ee..cc2c0f38e5 100644 --- a/internal/service/search.go +++ b/internal/service/search.go @@ -149,16 +149,6 @@ type CreateSearchResponse struct { // 5. Set fields: id, name, description, tenant_id, created_by // 6. Save to database within DB.atomic() transaction // 7. Return {search_id: id} on success -// -// Error handling from Python: -// - Name not string: "Search name must be string." -// - Name empty: "Search name can't be empty." -// - Name too long: "Search name length is X which is larger than 255." -// - Tenant not found: "Authorized identity." -// - Save failure: generic get_data_error_result() -// -// Note: Go implementation validates these in handler layer for cleaner separation -// Note: Similar pattern in: CreateMemory (memory.go), CreateDataset (datasets.go) func (s *SearchService) CreateSearch(userID string, name string, description *string) (*CreateSearchResponse, error) { // Generate UUID for search ID (same as Python get_uuid()) searchID := common.GenerateUUID() @@ -238,15 +228,105 @@ func (s *SearchService) GetSearchDetail(userID string, searchID string) (*entity func (s *SearchService) DeleteSearch(userID string, searchID string) error { // Step 1: Check deletion permission (same as Python SearchService.accessible4deletion) // Python: cls.model.select().where(cls.model.id == search_id, cls.model.created_by == user_id, cls.model.status == StatusEnum.VALID.value).first() - if !s.searchDAO.Accessible4Deletion(searchID, userID) { + + status, err := s.searchDAO.Accessible4Deletion(searchID, userID) + if err != nil { + return fmt.Errorf("failed to check deletion permission: %w", err) + } + + if !status { return fmt.Errorf("no authorization") } // Step 2: Execute delete (same as Python SearchService.delete_by_id) // Python: cls.model.delete().where(cls.model.id == pid).execute() - if err := s.searchDAO.DeleteByID(searchID); err != nil { + if err = s.searchDAO.DeleteByID(searchID); err != nil { return fmt.Errorf("failed to delete search App %s: %w", searchID, err) } return nil } + +// UpdateSearchRequest update search request +// Reference: api/apps/restful_apis/search_api.py::update +// Required fields: name, search_config +// Optional fields: description +// Immutable fields: search_id, tenant_id, created_by, update_time, id (will be removed) +type UpdateSearchRequest struct { + Name string `json:"name" binding:"required"` + Description *string `json:"description,omitempty"` + SearchConfig map[string]interface{} `json:"search_config" binding:"required"` +} + +func (s *SearchService) UpdateSearch(userID string, searchID string, req *UpdateSearchRequest) (*entity.Search, error) { + // Step 1: Check update permission (same as delete - uses accessible4deletion) + // Only creator can update + + status, err := s.searchDAO.Accessible4Deletion(searchID, userID) + if err != nil { + return nil, fmt.Errorf("failed to check deletion permission: %w", err) + } + + if !status { + return nil, fmt.Errorf("no authorization") + } + + // Step 2: Get existing search + // Python: search_app = SearchService.query(tenant_id=current_user.id, id=search_id)[0] + search, err := s.searchDAO.GetByTenantIDAndID(userID, searchID) + if err != nil { + return nil, fmt.Errorf("cannot find search %s", searchID) + } + + // Step 3: Check for duplicate name (if name changed) + // Python: if req["name"].lower() != search_app.name.lower() and len(SearchService.query(...)) >= 1 + trimmedName := req.Name + if search.Name != trimmedName { + existing, _ := s.searchDAO.GetByNameAndTenant(trimmedName, userID) + if len(existing) > 0 { + return nil, fmt.Errorf("duplicated search name") + } + } + + // Step 4: Merge search_config + // Python: req["search_config"] = {**current_config, **new_config} + currentConfig := search.SearchConfig + if currentConfig == nil { + currentConfig = make(entity.JSONMap) + } + mergedConfig := make(entity.JSONMap) + // Copy current config + for k, v := range currentConfig { + mergedConfig[k] = v + } + // Merge new config + for k, v := range req.SearchConfig { + mergedConfig[k] = v + } + + // Step 5: Prepare updates (excluding immutable fields) + // Python removes: search_id, tenant_id, created_by, update_time, id + updates := map[string]interface{}{ + "name": trimmedName, + "search_config": mergedConfig, + } + + if req.Description != nil { + updates["description"] = *req.Description + } + + // Step 6: Execute update + // Python: SearchService.update_by_id(search_id, req) + if err = s.searchDAO.UpdateByID(searchID, updates); err != nil { + return nil, fmt.Errorf("failed to update search: %w", err) + } + + // Step 7: Fetch updated search + // Python: e, updated_search = SearchService.get_by_id(search_id) + updatedSearch, err := s.searchDAO.GetByID(searchID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated search: %w", err) + } + + return updatedSearch, nil +}