From 5951e2b5645c7e895caa81cb9e26241afbd4c203 Mon Sep 17 00:00:00 2001 From: Jin Hai Date: Thu, 9 Apr 2026 20:04:06 +0800 Subject: [PATCH] Go: Add create search (#13998) ### What problem does this PR solve? As title ### Type of change - [x] New Feature (non-breaking change which adds functionality) --------- Signed-off-by: Jin Hai --- internal/common/app_name.go | 125 ++++++++++++++++++++++++++++++++ internal/common/time.go | 52 ++++++++++++++ internal/dao/search.go | 21 ++++++ internal/handler/search.go | 137 ++++++++++++++++++++++++++++++++++++ internal/router/router.go | 10 +-- internal/service/kb.go | 9 +-- internal/service/memory.go | 109 ++++------------------------ internal/service/search.go | 103 +++++++++++++++++++++++++++ internal/service/tenant.go | 12 ++-- 9 files changed, 464 insertions(+), 114 deletions(-) create mode 100644 internal/common/app_name.go create mode 100644 internal/common/time.go diff --git a/internal/common/app_name.go b/internal/common/app_name.go new file mode 100644 index 0000000000..a81ab4dd57 --- /dev/null +++ b/internal/common/app_name.go @@ -0,0 +1,125 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package common + +import ( + "fmt" + "path" + "regexp" + "strings" + + "github.com/google/uuid" +) + +// splitNameCounter splits a filename into base name and counter +// Handles names in format "filename(123)" pattern +// +// Parameters: +// - filename: The filename to split +// +// Returns: +// - string: The base name without counter +// - *int: The counter value, or nil if no counter exists +// +// Example: +// +// splitNameCounter("test(5)") returns ("test", 5) +// splitNameCounter("test") returns ("test", nil) +func splitNameCounter(filename string) (string, *int) { + re := regexp.MustCompile(`^(.+)\((\d+)\)$`) + matches := re.FindStringSubmatch(filename) + if len(matches) >= 3 { + counter := -1 + fmt.Sscanf(matches[2], "%d", &counter) + stem := strings.TrimRight(matches[1], " ") + return stem, &counter + } + return filename, nil +} + +// DuplicateName generates a unique name by appending a counter if the name already exists +// It tries up to 1000 times to generate a unique name +// +// Parameters: +// - queryFunc: Function to check if a name already exists (returns true if exists) +// - name: The original name +// - tenantID: The tenant ID for name uniqueness check +// +// Returns: +// - string: A unique name (either original or with counter appended) +// +// Example: +// +// DuplicateName(func(name string, tid string) bool { return false }, "test", "tenant1") returns "test" +// DuplicateName(func(name string, tid string) bool { return true }, "test", "tenant1") returns "test(1)" +func DuplicateName(queryFunc func(name string, tenantID string) bool, name string, tenantID string) (string, error) { + const maxRetries = 1000 + + originalName := name + currentName := name + retries := 0 + + for retries < maxRetries { + if !queryFunc(currentName, tenantID) { + return currentName, nil + } + + stem, counter := splitNameCounter(currentName) + ext := path.Ext(stem) + stemBase := strings.TrimSuffix(stem, ext) + + newCounter := 1 + if counter != nil { + newCounter = *counter + 1 + } + + currentName = fmt.Sprintf("%s(%d)%s", stemBase, newCounter, ext) + retries++ + + if err := ValidateName(currentName); err != nil { + return "", err + } + } + + return "", fmt.Errorf("failed to generate unique name after %d attempts, conflict name: %s", maxRetries, originalName) +} + +const AppNameLimit = 256 + +func ValidateName(name string) error { + // Validate name is not empty after trimming + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + return fmt.Errorf("name can't be empty") + } + + // Validate name length in bytes (not characters) - same as Python len(search_name.encode("utf-8")) + if len([]byte(name)) > AppNameLimit { + return fmt.Errorf("name length is %d which is large than %d", len([]byte(name)), AppNameLimit) + } + + return nil +} + +// GenerateUUID generates a UUID without dashes +func GenerateUUID() string { + newID := strings.ReplaceAll(uuid.New().String(), "-", "") + if len(newID) > 32 { + newID = newID[:32] + } + return newID +} diff --git a/internal/common/time.go b/internal/common/time.go new file mode 100644 index 0000000000..db64ca0064 --- /dev/null +++ b/internal/common/time.go @@ -0,0 +1,52 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package common + +import ( + "time" +) + +// DeltaSeconds calculates seconds elapsed from a given date string to now. +// +// Supports multiple time formats: +// - "YYYY-MM-DD HH:MM:SS" (e.g., "2024-01-01 12:00:00") +// - ISO 8601 / RFC3339 (e.g., "2026-04-09T18:55:46+08:00") +// +// Args: +// dateString: Date string in supported format +// +// Returns: +// float64: Number of seconds between the given date and current time +// +// Example: +// DeltaSeconds("2024-01-01 12:00:00") +// DeltaSeconds("2026-04-09T18:55:46+08:00") +func DeltaSeconds(dateString string) (float64, error) { + // Try RFC3339 format first (ISO 8601 with timezone, e.g., "2026-04-09T18:55:46+08:00") + dt, err := time.Parse(time.RFC3339, dateString) + if err == nil { + return time.Since(dt).Seconds(), nil + } + + // Try custom format without timezone (e.g., "2024-01-01 12:00:00") + const layout = "2006-01-02 15:04:05" + dt, err = time.ParseInLocation(layout, dateString, time.Local) + if err != nil { + return 0, err + } + return time.Since(dt).Seconds(), nil +} diff --git a/internal/dao/search.go b/internal/dao/search.go index 6f9a90b5e5..b229c40fff 100644 --- a/internal/dao/search.go +++ b/internal/dao/search.go @@ -124,3 +124,24 @@ func (dao *SearchDAO) GetByID(id string) (*entity.Search, error) { } return &search, nil } + +// GetByNameAndTenant gets search by name and tenant ID +func (dao *SearchDAO) GetByNameAndTenant(name string, tenantID string) ([]*entity.Search, error) { + var searches []*entity.Search + err := DB.Where("name = ? AND tenant_id = ? AND status = ?", name, tenantID, "1").Find(&searches).Error + return searches, err +} + +// Create creates a new search +func (dao *SearchDAO) Create(search *entity.Search) error { + return DB.Create(search).Error +} + +// QueryByTenantIDAndID checks if a search exists with given tenant_id and id +// Reference: Python SearchService.query(tenant_id=tenant.tenant_id, id=search_id) +// Used for permission verification in detail API +func (dao *SearchDAO) QueryByTenantIDAndID(tenantID string, searchID string) ([]*entity.Search, error) { + var searches []*entity.Search + err := DB.Where("tenant_id = ? AND id = ? AND status = ?", tenantID, searchID, "1").Find(&searches).Error + return searches, err +} diff --git a/internal/handler/search.go b/internal/handler/search.go index 1bb8cb0e12..942d813cf4 100644 --- a/internal/handler/search.go +++ b/internal/handler/search.go @@ -114,3 +114,140 @@ func (h *SearchHandler) ListSearches(c *gin.Context) { "message": "success", }) } + +// CreateSearch create a new search app +// @Summary Create Search App +// @Description Create a new search app for the current user +// @Tags search +// @Accept json +// @Produce json +// @Param request body service.CreateSearchRequest true "search creation parameters" +// @Success 200 {object} service.CreateSearchResponse +// @Router /api/v1/searches [post] + +type CreateSearchRequest struct { + Name string `json:"name" binding:"required"` // required field, max 255 bytes + Description *string `json:"description,omitempty"` // optional description +} + +func (h *SearchHandler) CreateSearch(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 + + // Parse request body (same as Python get_request_json()) + var req CreateSearchRequest + 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 + } + + if err := common.ValidateName(req.Name); err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": err.Error(), + }) + return + } + + // Create search (same as Python SearchService.save within DB.atomic()) + result, err := h.searchService.CreateSearch(userID, req.Name, req.Description) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": common.CodeServerError, + "data": nil, + "message": err.Error(), + }) + return + } + + // Return success response (same as Python get_json_result(data={"search_id": req["id"]})) + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": result, + "message": "success", + }) +} + +// GetSearch get search app detail +// @Summary Get Search App Detail +// @Description Get detail of a search app by ID +// @Tags search +// @Accept json +// @Produce json +// @Param search_id path string true "search app ID" +// @Success 200 {object} entity.Search +// @Router /api/v1/searches/{search_id} [get] +func (h *SearchHandler) GetSearch(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 + } + + // Get search detail with permission check + search, err := h.searchService.GetSearchDetail(userID, searchID) + if err != nil { + // Check if it's a permission error + if err.Error() == "has no permission for this operation" { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeOperatingError, + "data": false, + "message": "Has no permission for this operation.", + }) + return + } + // Not found error + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeDataError, + "data": nil, + "message": err.Error(), + }) + return + } + + // Convert to response format (same as Python get_json_result(data=search)) + result := map[string]interface{}{ + "id": search.ID, + "tenant_id": search.TenantID, + "name": search.Name, + "description": search.Description, + "created_by": search.CreatedBy, + "create_time": search.CreateTime, + "update_time": search.UpdateTime, + "search_config": search.SearchConfig, + } + + if search.Avatar != nil { + result["avatar"] = *search.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 adb255d798..3c70680c84 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -192,10 +192,12 @@ func (r *Router) Setup(engine *gin.Engine) { chats.GET("", r.chatHandler.ListChats) } - searches := v1.Group("/searches") - { - searches.GET("", r.searchHandler.ListSearches) - } + searches := v1.Group("/searches") + { + searches.GET("", r.searchHandler.ListSearches) + searches.POST("", r.searchHandler.CreateSearch) + searches.GET("/:search_id", r.searchHandler.GetSearch) + } file := v1.Group("/files") { diff --git a/internal/service/kb.go b/internal/service/kb.go index 56b46fc06c..42d049c884 100644 --- a/internal/service/kb.go +++ b/internal/service/kb.go @@ -28,8 +28,6 @@ import ( "ragflow/internal/utility" "strings" "time" - - "github.com/google/uuid" ) // KnowledgebaseService service class for managing dataset operations @@ -147,7 +145,7 @@ func (s *KnowledgebaseService) CreateKB(req *CreateKBRequest, tenantID string) ( parserConfig["llm_id"] = tenant.LLMID // Generate KB ID - kbID := strings.ReplaceAll(uuid.New().String(), "-", "") + kbID := common.GenerateUUID() // Create knowledge base model now := time.Now().Unix() @@ -551,11 +549,6 @@ func mergeParserConfig(base, override map[string]interface{}) map[string]interfa return result } -// GenerateUUID generates a UUID string without dashes -func GenerateUUID() string { - return strings.ReplaceAll(uuid.New().String(), "-", "") -} - // GetUserByToken gets user by authorization token func (s *KnowledgebaseService) GetUserByToken(authorization string) (*entity.User, common.ErrorCode, error) { userService := NewUserService() diff --git a/internal/service/memory.go b/internal/service/memory.go index a6e69aad11..2ab7272b08 100644 --- a/internal/service/memory.go +++ b/internal/service/memory.go @@ -19,15 +19,12 @@ package service import ( "errors" "fmt" - "path" + "ragflow/internal/common" "ragflow/internal/entity" - "regexp" "strconv" "strings" "time" - "github.com/google/uuid" - "ragflow/internal/dao" ) @@ -241,75 +238,6 @@ func NewMemoryService() *MemoryService { } } -// splitNameCounter splits a filename into base name and counter -// Handles names in format "filename(123)" pattern -// -// Parameters: -// - filename: The filename to split -// -// Returns: -// - string: The base name without counter -// - *int: The counter value, or nil if no counter exists -// -// Example: -// -// splitNameCounter("test(5)") returns ("test", 5) -// splitNameCounter("test") returns ("test", nil) -func splitNameCounter(filename string) (string, *int) { - re := regexp.MustCompile(`^(.+)\((\d+)\)$`) - matches := re.FindStringSubmatch(filename) - if len(matches) >= 3 { - counter := -1 - fmt.Sscanf(matches[2], "%d", &counter) - stem := strings.TrimRight(matches[1], " ") - return stem, &counter - } - return filename, nil -} - -// duplicateName generates a unique name by appending a counter if the name already exists -// It tries up to 1000 times to generate a unique name -// -// Parameters: -// - queryFunc: Function to check if a name already exists (returns true if exists) -// - name: The original name -// - tenantID: The tenant ID for name uniqueness check -// -// Returns: -// - string: A unique name (either original or with counter appended) -// -// Example: -// -// duplicateName(func(name string, tid string) bool { return false }, "test", "tenant1") returns "test" -// duplicateName(func(name string, tid string) bool { return true }, "test", "tenant1") returns "test(1)" -func duplicateName(queryFunc func(name string, tenantID string) bool, name string, tenantID string) string { - const maxRetries = 1000 - - originalName := name - currentName := name - retries := 0 - - for retries < maxRetries { - if !queryFunc(currentName, tenantID) { - return currentName - } - - stem, counter := splitNameCounter(currentName) - ext := path.Ext(stem) - stemBase := strings.TrimSuffix(stem, ext) - - newCounter := 1 - if counter != nil { - newCounter = *counter + 1 - } - - currentName = fmt.Sprintf("%s(%d)%s", stemBase, newCounter, ext) - retries++ - } - - panic(fmt.Sprintf("Failed to generate unique name within %d attempts. Original: %s", maxRetries, originalName)) -} - // CreateMemoryRequest defines the request structure for creating a memory type CreateMemoryRequest struct { // Name is the memory name (required, max 128 characters) @@ -410,14 +338,12 @@ func (s *MemoryService) CreateMemory(tenantID string, req *CreateMemoryRequest) req.TenantEmbdID = &tenantEmbdIDStr } - memoryName := strings.TrimSpace(req.Name) - if len(memoryName) == 0 { - return nil, errors.New("memory name cannot be empty or whitespace") - } - if len(memoryName) > MemoryNameLimit { - return nil, fmt.Errorf("memory name '%s' exceeds limit of %d", memoryName, MemoryNameLimit) + if err := common.ValidateName(req.Name); err != nil { + return nil, err } + memoryName := req.Name + if !isList(req.MemoryType) { return nil, errors.New("memory type must be a list") } @@ -435,13 +361,12 @@ func (s *MemoryService) CreateMemory(tenantID string, req *CreateMemoryRequest) uniqueMemoryTypes = append(uniqueMemoryTypes, mt) } - memoryName = duplicateName(func(name string, tid string) bool { + memoryName, err := common.DuplicateName(func(name string, tid string) bool { existing, _ := s.memoryDAO.GetByNameAndTenant(name, tid) return len(existing) > 0 }, memoryName, tenantID) - - if len(memoryName) > MemoryNameLimit { - return nil, fmt.Errorf("memory name %s exceeds limit of %d", memoryName, MemoryNameLimit) + if err != nil { + return nil, err } memoryTypeInt := dao.CalculateMemoryType(uniqueMemoryTypes) @@ -449,10 +374,7 @@ func (s *MemoryService) CreateMemory(tenantID string, req *CreateMemoryRequest) systemPrompt := PromptAssembler{}.AssembleSystemPrompt(uniqueMemoryTypes) - newID := strings.ReplaceAll(uuid.New().String(), "-", "") - if len(newID) > 32 { - newID = newID[:32] - } + newID := common.GenerateUUID() memory := &entity.Memory{ ID: newID, @@ -516,18 +438,15 @@ func (s *MemoryService) UpdateMemory(tenantID string, memoryID string, req *Upda if req.Name != nil { memoryName := strings.TrimSpace(*req.Name) - if len(memoryName) == 0 { - return nil, errors.New("memory name cannot be empty or whitespace") + if err := common.ValidateName(memoryName); err != nil { + return nil, err } - if len(memoryName) > MemoryNameLimit { - return nil, fmt.Errorf("memory name '%s' exceeds limit of %d", memoryName, MemoryNameLimit) - } - memoryName = duplicateName(func(name string, tid string) bool { + memoryName, err := common.DuplicateName(func(name string, tid string) bool { existing, _ := s.memoryDAO.GetByNameAndTenant(name, tid) return len(existing) > 0 }, memoryName, tenantID) - if len(memoryName) > MemoryNameLimit { - return nil, fmt.Errorf("memory name %s exceeds limit of %d", memoryName, MemoryNameLimit) + if err != nil { + return nil, err } updateDict["name"] = memoryName } diff --git a/internal/service/search.go b/internal/service/search.go index d189ed1783..1264191b1e 100644 --- a/internal/service/search.go +++ b/internal/service/search.go @@ -17,6 +17,8 @@ package service import ( + "fmt" + "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" ) @@ -130,3 +132,104 @@ func (s *SearchService) toSearchAppResponse(search *entity.Search) map[string]in return result } + +// CreateSearchResponse create search response +// Reference: api/apps/restful_apis/search_api.py::create - returns {"search_id": req["id"]} +type CreateSearchResponse struct { + SearchID string `json:"search_id"` // UUID format +} + +// CreateSearch creates a new search app +// Reference: api/apps/restful_apis/search_api.py::create +// Python implementation steps: +// 1. Get JSON request body with name (required) and description (optional) +// 2. Validate name is string, non-empty, and max 255 bytes +// 3. Generate unique name using duplicate_name(SearchService.query, name, tenant_id) +// 4. Generate UUID for search ID +// 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() + + // Generate unique name (same as Python duplicate_name) + uniqueName, err := common.DuplicateName(func(name string, tid string) bool { + existing, _ := s.searchDAO.GetByNameAndTenant(name, tid) + return len(existing) > 0 + }, name, userID) + + if err != nil { + return nil, err + } + + // Create search entity + search := &entity.Search{ + ID: searchID, + TenantID: userID, + Name: uniqueName, + CreatedBy: userID, + SearchConfig: make(entity.JSONMap), + } + + if description != nil { + search.Description = description + } + + // Set default status ("1" = valid/active, same as Python StatusEnum.VALID.value) + status := "1" + search.Status = &status + + // Save to database + if err := s.searchDAO.Create(search); err != nil { + return nil, fmt.Errorf("failed to create search: %w", err) + } + + return &CreateSearchResponse{ + SearchID: searchID, + }, nil +} + +func (s *SearchService) GetSearchDetail(userID string, searchID string) (*entity.Search, error) { + // Step 1: Get user tenants (same as Python UserTenantService.query(user_id=current_user.id)) + tenants, err := s.userTenantDAO.GetByUserID(userID) + if err != nil { + return nil, fmt.Errorf("failed to get user tenants: %w", err) + } + + // Step 2: Check if user has permission to access this search + // Python: for tenant in tenants: if SearchService.query(tenant_id=tenant.tenant_id, id=search_id): break + hasPermission := false + for _, tenant := range tenants { + searches, err := s.searchDAO.QueryByTenantIDAndID(tenant.TenantID, searchID) + if err != nil { + continue // Try next tenant + } + if len(searches) > 0 { + hasPermission = true + break + } + } + + if !hasPermission { + return nil, fmt.Errorf("has no permission for this operation") + } + + // Step 3: Get search detail (same as Python SearchService.get_detail(search_id)) + search, err := s.searchDAO.GetByID(searchID) + if err != nil { + return nil, fmt.Errorf("can't find this Search App!") + } + + return search, nil +} diff --git a/internal/service/tenant.go b/internal/service/tenant.go index e66bf5687d..1415eabd32 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -19,13 +19,11 @@ package service import ( "context" "fmt" - "ragflow/internal/entity" - "strings" - "time" - "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/engine" + "ragflow/internal/entity" + "strings" ) // TenantService tenant service @@ -232,14 +230,14 @@ func (s *TenantService) GetTenantList(userID string) ([]*TenantListItem, error) } result := make([]*TenantListItem, len(tenants)) - now := time.Now() for i, t := range tenants { // Parse update_date and calculate delta_seconds var deltaSeconds float64 if t.UpdateDate != "" { - if updateTime, err := time.Parse("2006-01-02 15:04:05", t.UpdateDate); err == nil { - deltaSeconds = now.Sub(updateTime).Seconds() + deltaSeconds, err = common.DeltaSeconds(t.UpdateDate) + if err != nil { + return nil, err } }