mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-01 08:15:44 +08:00
### What problem does this PR solve? Refact functions in engine in GO ### Type of change - [x] Refactoring
633 lines
23 KiB
Go
633 lines
23 KiB
Go
//
|
|
// 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 service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/dao"
|
|
"ragflow/internal/engine"
|
|
"ragflow/internal/entity"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// SkillSpaceService handles business logic for skills space operations
|
|
type SkillSpaceService struct {
|
|
spaceDAO *dao.SkillSpaceDAO
|
|
fileDAO *dao.FileDAO
|
|
configDAO *dao.SkillSearchConfigDAO
|
|
tenantDAO *dao.TenantDAO
|
|
skillsFolderCache map[string]string // tenant-keyed cache for skills folder ID
|
|
skillsFolderMu sync.RWMutex // protects skillsFolderCache
|
|
skillsFolderCreateMu sync.Map // tenant-scoped locks for folder creation
|
|
spaceCreateMu sync.Map // tenant-scoped locks for space creation (prevents TOCTOU races)
|
|
}
|
|
|
|
// NewSkillSpaceService creates a new SkillSpaceService instance
|
|
func NewSkillSpaceService() *SkillSpaceService {
|
|
return &SkillSpaceService{
|
|
spaceDAO: dao.NewSkillSpaceDAO(),
|
|
fileDAO: dao.NewFileDAO(),
|
|
configDAO: dao.NewSkillSearchConfigDAO(),
|
|
tenantDAO: dao.NewTenantDAO(),
|
|
skillsFolderCache: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// CreateSpaceRequest represents the request to create a skills space
|
|
type CreateSpaceRequest struct {
|
|
TenantID string `json:"tenant_id" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Description string `json:"description"`
|
|
EmbdID string `json:"embd_id"`
|
|
RerankID string `json:"rerank_id"`
|
|
}
|
|
|
|
// UpdateSpaceRequest represents the request to update a skills space
|
|
type UpdateSpaceRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
EmbdID string `json:"embd_id"`
|
|
RerankID string `json:"rerank_id"`
|
|
TopK int `json:"top_k"`
|
|
}
|
|
|
|
// getSkillsFolderID gets or creates the skills folder for a tenant
|
|
// Uses tenant-scoped locking to prevent duplicate folder creation
|
|
func (s *SkillSpaceService) getSkillsFolderID(tenantID string) (string, error) {
|
|
// Return cached value if available (read lock)
|
|
s.skillsFolderMu.RLock()
|
|
if cachedID, ok := s.skillsFolderCache[tenantID]; ok && cachedID != "" {
|
|
s.skillsFolderMu.RUnlock()
|
|
return cachedID, nil
|
|
}
|
|
s.skillsFolderMu.RUnlock()
|
|
|
|
// Acquire tenant-scoped creation lock
|
|
lock, _ := s.skillsFolderCreateMu.LoadOrStore(tenantID, &sync.Mutex{})
|
|
lock.(*sync.Mutex).Lock()
|
|
defer lock.(*sync.Mutex).Unlock()
|
|
|
|
// Double-check cache after acquiring lock
|
|
s.skillsFolderMu.RLock()
|
|
if cachedID, ok := s.skillsFolderCache[tenantID]; ok && cachedID != "" {
|
|
s.skillsFolderMu.RUnlock()
|
|
return cachedID, nil
|
|
}
|
|
s.skillsFolderMu.RUnlock()
|
|
|
|
// Get root folder
|
|
rootFolder, err := s.fileDAO.GetRootFolder(tenantID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get root folder: %w", err)
|
|
}
|
|
|
|
// Look for skills folder under root
|
|
files, _, err := s.fileDAO.GetByPfID(tenantID, rootFolder.ID, 0, 0, "name", false, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to list root folder contents: %w", err)
|
|
}
|
|
|
|
for _, file := range files {
|
|
if file.Type == "folder" && file.Name == "skills" {
|
|
// Cache the result (write lock)
|
|
s.skillsFolderMu.Lock()
|
|
s.skillsFolderCache[tenantID] = file.ID
|
|
s.skillsFolderMu.Unlock()
|
|
return file.ID, nil
|
|
}
|
|
}
|
|
|
|
// Skills folder not found, create it
|
|
common.Info("Creating skills folder", zap.String("tenant_id", tenantID))
|
|
folderID := generateSpaceID()
|
|
folder := &entity.File{
|
|
ID: folderID,
|
|
ParentID: rootFolder.ID,
|
|
TenantID: tenantID,
|
|
CreatedBy: tenantID,
|
|
Name: "skills",
|
|
Type: "folder",
|
|
Size: 0,
|
|
SourceType: "system",
|
|
}
|
|
|
|
if err := s.fileDAO.Create(folder); err != nil {
|
|
return "", fmt.Errorf("failed to create skills folder: %w", err)
|
|
}
|
|
|
|
// Cache the result (write lock)
|
|
s.skillsFolderMu.Lock()
|
|
s.skillsFolderCache[tenantID] = folderID
|
|
s.skillsFolderMu.Unlock()
|
|
|
|
return folderID, nil
|
|
}
|
|
|
|
// CreateSpace creates a new skills space with associated folder
|
|
func (s *SkillSpaceService) CreateSpace(req *CreateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) {
|
|
// Validate name
|
|
if req.Name == "" {
|
|
return nil, common.CodeDataError, fmt.Errorf("space name is required")
|
|
}
|
|
|
|
// Tenant-scoped serialization to prevent concurrent create/delete races
|
|
tenantKey := req.TenantID + ":" + req.Name
|
|
mu, _ := s.spaceCreateMu.LoadOrStore(tenantKey, &sync.Mutex{})
|
|
tenantMu := mu.(*sync.Mutex)
|
|
tenantMu.Lock()
|
|
defer func() {
|
|
tenantMu.Unlock()
|
|
s.spaceCreateMu.Delete(tenantKey)
|
|
}()
|
|
|
|
// Double-check after acquiring lock: Check if space with same name already exists (active status)
|
|
existingSpace, err := s.spaceDAO.GetByTenantAndName(req.TenantID, req.Name)
|
|
if err != nil {
|
|
// Space doesn't exist, continue
|
|
} else if existingSpace != nil {
|
|
return nil, common.CodeDataError, fmt.Errorf("space with name '%s' already exists", req.Name)
|
|
}
|
|
|
|
// Check if there's a space with the same name that is currently being deleted
|
|
existingSpaceAny, err := s.spaceDAO.GetByTenantAndNameAnyStatus(req.TenantID, req.Name)
|
|
if err == nil && existingSpaceAny != nil && existingSpaceAny.Status == entity.SpaceStatusDeleting {
|
|
return nil, common.CodeDataError, fmt.Errorf("space with name '%s' is being deleted, please try again later", req.Name)
|
|
}
|
|
|
|
// Check if there's a deleted/non-active space with the same name and permanently delete it
|
|
// This handles the case where a previous creation failed partially
|
|
// Only delete non-active spaces (status != '1') to prevent TOCTOU race
|
|
if err := s.spaceDAO.DeletePermanentByName(req.TenantID, req.Name); err != nil {
|
|
common.Warn("Failed to delete permanent space by name", zap.Error(err))
|
|
}
|
|
|
|
// Get skills folder ID
|
|
skillsFolderID, err := s.getSkillsFolderID(req.TenantID)
|
|
if err != nil {
|
|
common.Error("Failed to get skills folder ID", err)
|
|
return nil, common.CodeOperatingError, err
|
|
}
|
|
|
|
// Check if there's an existing folder with the same name under skills folder
|
|
// If exists, delete it to prevent duplicate folder names
|
|
existingFolders := s.fileDAO.Query(req.Name, skillsFolderID)
|
|
for _, f := range existingFolders {
|
|
if f.Type == "folder" && f.Name == req.Name {
|
|
common.Info("Deleting existing space folder with same name", zap.String("folderID", f.ID), zap.String("name", req.Name))
|
|
if err := s.deleteFolderRecursive(f.ID); err != nil {
|
|
common.Warn("Failed to delete existing folder", zap.String("folderID", f.ID), zap.Error(err))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Generate space ID and folder ID
|
|
spaceID := generateSpaceID()
|
|
folderID := generateSpaceID()
|
|
|
|
// Create folder for the space under skills folder
|
|
folder := &entity.File{
|
|
ID: folderID,
|
|
ParentID: skillsFolderID,
|
|
TenantID: req.TenantID,
|
|
CreatedBy: req.TenantID,
|
|
Name: req.Name,
|
|
Type: "folder",
|
|
Size: 0,
|
|
SourceType: "skill_space",
|
|
}
|
|
|
|
if err := s.fileDAO.Create(folder); err != nil {
|
|
common.Error("Failed to create space folder", err)
|
|
return nil, common.CodeOperatingError, fmt.Errorf("failed to create space folder: %w", err)
|
|
}
|
|
|
|
// Create the space
|
|
space := &entity.SkillSpace{
|
|
ID: spaceID,
|
|
TenantID: req.TenantID,
|
|
Name: req.Name,
|
|
FolderID: folderID,
|
|
Description: req.Description,
|
|
EmbdID: req.EmbdID,
|
|
RerankID: req.RerankID,
|
|
TopK: 10,
|
|
Status: "1",
|
|
}
|
|
|
|
if err := s.spaceDAO.Create(space); err != nil {
|
|
// Rollback: delete the created folder
|
|
common.Error("Failed to create space in database", err)
|
|
s.fileDAO.DeleteByIDs([]string{folderID})
|
|
return nil, common.CodeOperatingError, fmt.Errorf("failed to create space: %w", err)
|
|
}
|
|
|
|
// Create default search config for this space
|
|
defaultEmbdID := req.EmbdID
|
|
if defaultEmbdID == "" {
|
|
tenant, err := s.tenantDAO.GetByID(req.TenantID)
|
|
if err == nil && tenant != nil && tenant.EmbdID != "" {
|
|
defaultEmbdID = tenant.EmbdID
|
|
common.Info("Using tenant default embedding model", zap.String("tenantID", req.TenantID), zap.String("embdID", defaultEmbdID))
|
|
} else {
|
|
common.Warn("Tenant has no default embedding model, skill search will not work until configured", zap.String("tenantID", req.TenantID))
|
|
}
|
|
}
|
|
if defaultEmbdID != "" {
|
|
if _, err := s.configDAO.GetOrCreate(req.TenantID, spaceID, defaultEmbdID); err != nil {
|
|
common.Warn("Failed to create skill search config for new space",
|
|
zap.String("tenantID", req.TenantID),
|
|
zap.String("spaceID", spaceID),
|
|
zap.String("embdID", defaultEmbdID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
return space.ToMap(), common.CodeSuccess, nil
|
|
}
|
|
|
|
// ListSpaces lists all skills spaces for a tenant
|
|
func (s *SkillSpaceService) ListSpaces(tenantID string) (map[string]interface{}, common.ErrorCode, error) {
|
|
spaces, err := s.spaceDAO.GetByTenantID(tenantID)
|
|
if err != nil {
|
|
return nil, common.CodeOperatingError, fmt.Errorf("failed to list spaces: %w", err)
|
|
}
|
|
|
|
// Convert to maps
|
|
spaceList := make([]map[string]interface{}, len(spaces))
|
|
for i, space := range spaces {
|
|
spaceList[i] = space.ToMap()
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"spaces": spaceList,
|
|
"total": len(spaceList),
|
|
}, common.CodeSuccess, nil
|
|
}
|
|
|
|
// GetSpace retrieves a skills space by ID (includes deleting status for visibility)
|
|
func (s *SkillSpaceService) GetSpace(spaceID, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
|
|
space, err := s.spaceDAO.GetByIDAnyStatus(spaceID)
|
|
if err != nil {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
// Verify tenant ownership
|
|
if space.TenantID != tenantID {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
// Return deleted spaces as not found
|
|
if space.Status == entity.SpaceStatusDeleted {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
return space.ToMap(), common.CodeSuccess, nil
|
|
}
|
|
|
|
// UpdateSpace updates a skills space
|
|
func (s *SkillSpaceService) UpdateSpace(spaceID string, tenantID string, req *UpdateSpaceRequest) (map[string]interface{}, common.ErrorCode, error) {
|
|
space, err := s.spaceDAO.GetByID(spaceID)
|
|
if err != nil {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
// Verify tenant ownership
|
|
if space.TenantID != tenantID {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
// Build updates
|
|
updates := make(map[string]interface{})
|
|
|
|
if req.Name != "" && req.Name != space.Name {
|
|
// Check if name already exists
|
|
existingSpace, _ := s.spaceDAO.GetByTenantAndName(tenantID, req.Name)
|
|
if existingSpace != nil && existingSpace.ID != spaceID {
|
|
return nil, common.CodeDataError, fmt.Errorf("space with name '%s' already exists", req.Name)
|
|
}
|
|
|
|
originalName := space.Name
|
|
updates["name"] = req.Name
|
|
|
|
// Update space first, then folder (atomic-like behavior with rollback on failure)
|
|
if err := s.spaceDAO.UpdateByID(spaceID, updates); err != nil {
|
|
return nil, common.CodeOperatingError, fmt.Errorf("failed to update space name: %w", err)
|
|
}
|
|
|
|
// Update folder name as well - if this fails, rollback space name
|
|
if err := s.fileDAO.UpdateByID(space.FolderID, map[string]interface{}{"name": req.Name}); err != nil {
|
|
common.Error("Failed to update folder name, rolling back space name", err)
|
|
// Rollback space name
|
|
if rollbackErr := s.spaceDAO.UpdateByID(spaceID, map[string]interface{}{"name": originalName}); rollbackErr != nil {
|
|
common.Error("Failed to rollback space name after folder rename failure", rollbackErr)
|
|
}
|
|
return nil, common.CodeOperatingError, fmt.Errorf("failed to update folder name: %w", err)
|
|
}
|
|
|
|
// Clear updates map since we've already applied name change
|
|
delete(updates, "name")
|
|
}
|
|
|
|
if req.Description != space.Description {
|
|
updates["description"] = req.Description
|
|
}
|
|
if req.EmbdID != "" && req.EmbdID != space.EmbdID {
|
|
updates["embd_id"] = req.EmbdID
|
|
}
|
|
if req.RerankID != space.RerankID {
|
|
updates["rerank_id"] = req.RerankID
|
|
}
|
|
if req.TopK > 0 && req.TopK != space.TopK {
|
|
updates["top_k"] = req.TopK
|
|
}
|
|
|
|
if len(updates) > 0 {
|
|
if err := s.spaceDAO.UpdateByID(spaceID, updates); err != nil {
|
|
return nil, common.CodeOperatingError, fmt.Errorf("failed to update space: %w", err)
|
|
}
|
|
}
|
|
|
|
// Refresh space data
|
|
space, _ = s.spaceDAO.GetByID(spaceID)
|
|
return space.ToMap(), common.CodeSuccess, nil
|
|
}
|
|
|
|
// getPythonServiceURL returns the Python service URL from environment or default
|
|
func getPythonServiceURL() string {
|
|
url := os.Getenv("PYTHON_SERVICE_URL")
|
|
if url == "" {
|
|
url = "http://127.0.0.1:9380"
|
|
}
|
|
// Ensure URL has scheme
|
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
|
url = "http://" + url
|
|
}
|
|
// Ensure URL has the API path
|
|
if !strings.HasSuffix(url, "/api/v1/files") {
|
|
url = strings.TrimSuffix(url, "/")
|
|
url = url + "/api/v1/files"
|
|
}
|
|
return url
|
|
}
|
|
|
|
// deleteFolderViaPythonAPI calls Python backend API to delete folder and its storage
|
|
func (s *SkillSpaceService) deleteFolderViaPythonAPI(folderID, tenantID, authHeader string) error {
|
|
pythonURL := getPythonServiceURL()
|
|
|
|
reqBody := map[string]interface{}{
|
|
"ids": []string{folderID},
|
|
}
|
|
jsonData, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("DELETE", pythonURL, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Use request context with timeout to prevent indefinite blocking
|
|
deleteCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
defer cancel()
|
|
req = req.WithContext(deleteCtx)
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// Extract raw token from "Bearer <token>" format if present
|
|
// Python backend needs the raw token for authentication
|
|
authToken := authHeader
|
|
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
|
authToken = strings.TrimSpace(authHeader[7:])
|
|
}
|
|
req.Header.Set("Authorization", authToken)
|
|
// Set tenant ID header for Python backend
|
|
req.Header.Set("X-tenant-id", tenantID)
|
|
|
|
common.Info("Calling Python API to delete folder", zap.String("folderID", folderID), zap.String("tenantID", tenantID))
|
|
|
|
client := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to call Python API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
common.Info("Python API delete folder response", zap.String("folderID", folderID), zap.Int("status", resp.StatusCode), zap.String("body", string(body)))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("Python API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Parse response to check if deletion was successful
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
if code, ok := result["code"].(float64); !ok || int(code) != 0 {
|
|
message := "unknown error"
|
|
if msg, ok := result["message"].(string); ok {
|
|
message = msg
|
|
}
|
|
return fmt.Errorf("Python API returned error: %s", message)
|
|
}
|
|
|
|
common.Info("Successfully deleted folder via Python API", zap.String("folderID", folderID))
|
|
return nil
|
|
}
|
|
|
|
// DeleteSpace starts asynchronous deletion of a skills space and returns immediately.
|
|
// The space status is set to "deleting" and the actual cleanup runs in a background goroutine.
|
|
func (s *SkillSpaceService) DeleteSpace(spaceID, tenantID string, docEngine engine.DocEngine, authHeader string) (common.ErrorCode, error) {
|
|
// Get space regardless of status (could be retrying a failed delete)
|
|
space, err := s.spaceDAO.GetByIDAnyStatus(spaceID)
|
|
if err != nil {
|
|
return common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
// Verify tenant ownership
|
|
if space.TenantID != tenantID {
|
|
return common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
// If already deleting, return success (idempotent)
|
|
if space.Status == entity.SpaceStatusDeleting {
|
|
common.Info("Space is already being deleted", zap.String("spaceID", spaceID))
|
|
return common.CodeSuccess, nil
|
|
}
|
|
|
|
// If already deleted, return success (idempotent)
|
|
if space.Status == entity.SpaceStatusDeleted {
|
|
common.Info("Space is already deleted", zap.String("spaceID", spaceID))
|
|
return common.CodeSuccess, nil
|
|
}
|
|
|
|
// CAS: status must be "1" (active) → "2" (deleting) to prevent concurrent deletes
|
|
swapped, err := s.spaceDAO.CASStatus(spaceID, entity.SpaceStatusActive, entity.SpaceStatusDeleting)
|
|
if err != nil {
|
|
return common.CodeOperatingError, fmt.Errorf("failed to update space status: %w", err)
|
|
}
|
|
if !swapped {
|
|
// Another request already changed the status
|
|
return common.CodeOperatingError, fmt.Errorf("space is being modified by another request")
|
|
}
|
|
|
|
common.Info("Space marked as deleting, starting async cleanup", zap.String("spaceID", spaceID), zap.String("tenantID", tenantID))
|
|
|
|
// Launch async deletion in background goroutine
|
|
go s.asyncDeleteSpace(spaceID, space.FolderID, tenantID, docEngine, authHeader)
|
|
|
|
return common.CodeSuccess, nil
|
|
}
|
|
|
|
// asyncDeleteSpace performs the actual deletion work in the background.
|
|
// It deletes the search index, removes files via Python API, and soft-deletes the space record.
|
|
func (s *SkillSpaceService) asyncDeleteSpace(spaceID, folderID, tenantID string, docEngine engine.DocEngine, authHeader string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
common.Warn("Panic in asyncDeleteSpace, marking space as deleted", zap.Any("recover", r), zap.String("spaceID", spaceID))
|
|
_, _ = s.spaceDAO.CASStatus(spaceID, entity.SpaceStatusDeleting, entity.SpaceStatusDeleted)
|
|
}
|
|
}()
|
|
|
|
// Step 1: Delete the search index
|
|
if docEngine != nil {
|
|
indexName := getSkillIndexName(tenantID, spaceID)
|
|
common.Info("Async deleting space index", zap.String("index", indexName), zap.String("spaceID", spaceID))
|
|
deleteCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
if err := docEngine.DropChunkStore(deleteCtx, indexName, "skill"); err != nil {
|
|
common.Warn("Failed to delete space index during async delete", zap.String("index", indexName), zap.Error(err))
|
|
// Continue with other cleanup steps
|
|
} else {
|
|
common.Info("Successfully deleted space index", zap.String("index", indexName))
|
|
}
|
|
cancel()
|
|
}
|
|
|
|
// Step 2: Delete folder and storage via Python API
|
|
common.Info("Async deleting space folder via Python API", zap.String("folderID", folderID), zap.String("spaceID", spaceID))
|
|
if err := s.deleteFolderViaPythonAPI(folderID, tenantID, authHeader); err != nil {
|
|
common.Error(fmt.Sprintf("Failed to delete space folder via Python API during async delete, spaceID=%s", spaceID), err)
|
|
// Retry once with a delay
|
|
time.Sleep(5 * time.Second)
|
|
if retryErr := s.deleteFolderViaPythonAPI(folderID, tenantID, authHeader); retryErr != nil {
|
|
common.Error(fmt.Sprintf("Retry failed to delete space folder, marking space as deleted anyway, spaceID=%s", spaceID), retryErr)
|
|
// Mark as deleted even if folder deletion fails - orphaned folders can be cleaned up later
|
|
}
|
|
} else {
|
|
common.Info("Successfully deleted space folder via Python API", zap.String("folderID", folderID))
|
|
}
|
|
|
|
// Step 3: Soft delete the space record (status "2" → "0")
|
|
// First, permanently remove any previously deleted spaces with the same tenant+name
|
|
// to avoid UNIQUE INDEX constraint violation when changing status from "2" to "0"
|
|
space, err := s.spaceDAO.GetByIDAnyStatus(spaceID)
|
|
if err == nil && space != nil {
|
|
_ = s.spaceDAO.DeletePermanentByName(space.TenantID, space.Name)
|
|
}
|
|
|
|
swapped, err := s.spaceDAO.CASStatus(spaceID, entity.SpaceStatusDeleting, entity.SpaceStatusDeleted)
|
|
if err != nil {
|
|
common.Error(fmt.Sprintf("Failed to update space status to deleted, spaceID=%s", spaceID), err)
|
|
return
|
|
}
|
|
if !swapped {
|
|
common.Warn("Space status was not 'deleting' when trying to mark as deleted", zap.String("spaceID", spaceID))
|
|
return
|
|
}
|
|
|
|
common.Info("Successfully completed async space deletion", zap.String("spaceID", spaceID))
|
|
}
|
|
|
|
// deleteFolderRecursive recursively deletes a folder and all its contents
|
|
func (s *SkillSpaceService) deleteFolderRecursive(folderID string) error {
|
|
// Get all children
|
|
children, err := s.fileDAO.ListByParentID(folderID)
|
|
if err != nil {
|
|
common.Error(fmt.Sprintf("Failed to list children for folder %s", folderID), err)
|
|
return err
|
|
}
|
|
|
|
common.Info("Deleting folder contents", zap.String("folder_id", folderID), zap.Int("child_count", len(children)))
|
|
|
|
// Collect file IDs (non-folder) and recurse into subfolders
|
|
var fileIDs []string
|
|
for _, child := range children {
|
|
if child.Type == "folder" {
|
|
common.Debug("Recursively deleting child folder", zap.String("folder_id", child.ID), zap.String("folder_name", child.Name))
|
|
if err := s.deleteFolderRecursive(child.ID); err != nil {
|
|
common.Warn("Failed to delete child folder", zap.String("folder_id", child.ID), zap.Error(err))
|
|
}
|
|
} else {
|
|
// Collect non-folder files for batch deletion
|
|
common.Debug("Collecting file for deletion", zap.String("file_id", child.ID), zap.String("file_name", child.Name))
|
|
fileIDs = append(fileIDs, child.ID)
|
|
}
|
|
}
|
|
|
|
// Delete all non-folder files in batch
|
|
if len(fileIDs) > 0 {
|
|
common.Info("Deleting files in folder", zap.String("folder_id", folderID), zap.Int("file_count", len(fileIDs)))
|
|
if _, err := s.fileDAO.DeleteByIDs(fileIDs); err != nil {
|
|
common.Warn("Failed to delete files in folder", zap.String("folder_id", folderID), zap.Strings("file_ids", fileIDs), zap.Error(err))
|
|
// Continue to delete folder even if file deletion fails
|
|
}
|
|
}
|
|
|
|
// Delete the folder itself
|
|
common.Info("Deleting folder", zap.String("folder_id", folderID))
|
|
_, err = s.fileDAO.DeleteByIDs([]string{folderID})
|
|
if err != nil {
|
|
common.Error(fmt.Sprintf("Failed to delete folder %s", folderID), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetSpaceByFolderID retrieves a skills space by its folder ID
|
|
func (s *SkillSpaceService) GetSpaceByFolderID(folderID, tenantID string) (map[string]interface{}, common.ErrorCode, error) {
|
|
space, err := s.spaceDAO.GetByFolderID(folderID)
|
|
if err != nil {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found for folder")
|
|
}
|
|
|
|
// Verify tenant ownership
|
|
if space.TenantID != tenantID {
|
|
return nil, common.CodeDataError, fmt.Errorf("space not found")
|
|
}
|
|
|
|
return space.ToMap(), common.CodeSuccess, nil
|
|
}
|
|
|
|
// generateSpaceID generates a unique ID for space
|
|
func generateSpaceID() string {
|
|
return strings.ReplaceAll(uuid.New().String(), "-", "")[:32]
|
|
}
|