mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
feat(go-api): Implement BulkDeleteChats Go API and fix ListChats (#16157)
### Description - **Bulk Delete Chats**: Implemented Go endpoint `DELETE /api/v1/chats` supporting bulk delete by `ids`, `delete_all` flag, and backward-compatible `chat_id` body payload (with tenant-ownership security checks). - **Bug Fix**: Fixed a parameter swap in Go `ListChats` handler to properly exclude soft-deleted chats.
This commit is contained in:
@@ -282,6 +282,135 @@ func (h *ChatHandler) RemoveChats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteChat soft deletes a chat by ID.
|
||||
func (h *ChatHandler) DeleteChat(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
userID := user.ID
|
||||
|
||||
chatID := c.Param("chat_id")
|
||||
if chatID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": common.CodeBadRequest,
|
||||
"data": nil,
|
||||
"message": "chat_id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.chatService.DeleteChat(userID, chatID); err != nil {
|
||||
if err.Error() == "no authorization" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeAuthenticationError,
|
||||
"data": false,
|
||||
"message": "No authorization.",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeDataError,
|
||||
"data": nil,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": true,
|
||||
"message": "success",
|
||||
})
|
||||
}
|
||||
|
||||
// BulkDeleteChats soft deletes multiple chats owned by the current user.
|
||||
func (h *ChatHandler) BulkDeleteChats(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
userID := user.ID
|
||||
if c.Request.Body == nil || c.Request.ContentLength == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": map[string]interface{}{},
|
||||
"message": "success",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.BulkDeleteChatsRequest
|
||||
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 len(req.IDs) == 0 && !req.DeleteAll {
|
||||
if req.ChatID != "" {
|
||||
if err := h.chatService.DeleteChat(userID, req.ChatID); err != nil {
|
||||
if err.Error() == "no authorization" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeAuthenticationError,
|
||||
"data": false,
|
||||
"message": "No authorization.",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeDataError,
|
||||
"data": nil,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": true,
|
||||
"message": "success",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": map[string]interface{}{},
|
||||
"message": "success",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.chatService.BulkDeleteChats(userID, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeDataError,
|
||||
"data": nil,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message := "success"
|
||||
if errorsList, ok := result["errors"].([]string); ok && len(errorsList) > 0 {
|
||||
if successCount, ok := result["success_count"].(int); ok {
|
||||
message = "Partially deleted " + strconv.Itoa(successCount) + " chats with " + strconv.Itoa(len(errorsList)) + " errors"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeSuccess,
|
||||
"data": result,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// GetChat get chat detail
|
||||
// @Summary Get Chat Detail
|
||||
// @Description Get detail of a chat by ID
|
||||
|
||||
122
internal/handler/chat_test.go
Normal file
122
internal/handler/chat_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
func setupChatHandlerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
TranslateError: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&entity.Chat{}); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
|
||||
origDB := dao.DB
|
||||
dao.DB = db
|
||||
t.Cleanup(func() { dao.DB = origDB })
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func createChatHandlerTestChat(t *testing.T, db *gorm.DB, id, tenantID string) {
|
||||
t.Helper()
|
||||
|
||||
name := "chat-" + id
|
||||
status := string(entity.StatusValid)
|
||||
chat := &entity.Chat{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: &name,
|
||||
LLMID: "model-a",
|
||||
LLMSetting: entity.JSONMap{},
|
||||
PromptType: "simple",
|
||||
PromptConfig: entity.JSONMap{"system": "sys"},
|
||||
KBIDs: entity.JSONSlice{},
|
||||
Status: &status,
|
||||
}
|
||||
if err := db.Create(chat).Error; err != nil {
|
||||
t.Fatalf("failed to create chat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteChatHandlerSuccess(t *testing.T) {
|
||||
db := setupChatHandlerTestDB(t)
|
||||
createChatHandlerTestChat(t, db, "chat-1", "user-1")
|
||||
|
||||
h := NewChatHandler(service.NewChatService(), service.NewUserService())
|
||||
c, w := setupGinContextWithUser("DELETE", "/api/v1/chats/chat-1", "")
|
||||
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
|
||||
|
||||
h.DeleteChat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Fatalf("expected success code, got %v", resp["code"])
|
||||
}
|
||||
if resp["data"] != true {
|
||||
t.Fatalf("expected data=true, got %v", resp["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteChatsHandlerPartialSuccess(t *testing.T) {
|
||||
db := setupChatHandlerTestDB(t)
|
||||
createChatHandlerTestChat(t, db, "chat-1", "user-1")
|
||||
createChatHandlerTestChat(t, db, "chat-2", "tenant-2")
|
||||
|
||||
h := NewChatHandler(service.NewChatService(), service.NewUserService())
|
||||
c, w := setupGinContextWithUser("DELETE", "/api/v1/chats", `{"ids":["chat-1","chat-2","chat-1"]}`)
|
||||
|
||||
h.BulkDeleteChats(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["code"] != float64(common.CodeSuccess) {
|
||||
t.Fatalf("expected success code, got %v", resp["code"])
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object data, got %+v", resp["data"])
|
||||
}
|
||||
if data["success_count"] != float64(1) {
|
||||
t.Fatalf("expected success_count=1, got %v", data["success_count"])
|
||||
}
|
||||
errorsList, ok := data["errors"].([]interface{})
|
||||
if !ok || len(errorsList) != 2 {
|
||||
t.Fatalf("expected 2 errors, got %+v", data["errors"])
|
||||
}
|
||||
if resp["message"] != "Partially deleted 1 chats with 2 errors" {
|
||||
t.Fatalf("unexpected message: %v", resp["message"])
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,8 @@ func (r *Router) Setup(engine *gin.Engine) {
|
||||
chats := v1.Group("/chats")
|
||||
{
|
||||
chats.GET("", r.chatHandler.ListChats)
|
||||
chats.DELETE("", r.chatHandler.BulkDeleteChats)
|
||||
chats.DELETE("/:chat_id", r.chatHandler.DeleteChat)
|
||||
chats.GET("/:chat_id", r.chatHandler.GetChat)
|
||||
chats.GET("/:chat_id/sessions", r.chatSessionHandler.ListChatSessions)
|
||||
}
|
||||
|
||||
@@ -644,6 +644,110 @@ func getEmbdIDs(kbs []*entity.Knowledgebase) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *ChatService) getOwnedValidChat(userID, chatID string) (*entity.Chat, error) {
|
||||
chat, err := s.chatDAO.GetByIDAndStatus(chatID, string(entity.StatusValid))
|
||||
if err != nil {
|
||||
return nil, errors.New("no authorization")
|
||||
}
|
||||
if chat.TenantID != userID {
|
||||
return nil, errors.New("no authorization")
|
||||
}
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
// DeleteChat soft deletes a single chat owned by the current user.
|
||||
func (s *ChatService) DeleteChat(userID, chatID string) error {
|
||||
if _, err := s.getOwnedValidChat(userID, chatID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.chatDAO.UpdateByID(chatID, map[string]interface{}{
|
||||
"status": string(entity.StatusInvalid),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("Failed to delete chat %s", chatID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkDeleteChatsRequest matches DELETE /api/v1/chats request semantics.
|
||||
type BulkDeleteChatsRequest struct {
|
||||
IDs []string `json:"ids,omitempty"`
|
||||
DeleteAll bool `json:"delete_all,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
// checkDuplicateChatIDs
|
||||
func checkDuplicateChatIDs(ids []string) ([]string, []string) {
|
||||
idCount := make(map[string]int, len(ids))
|
||||
uniqueIDs := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
idCount[id]++
|
||||
if idCount[id] == 1 {
|
||||
uniqueIDs = append(uniqueIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
duplicateMessages := make([]string, 0)
|
||||
for id, count := range idCount {
|
||||
if count > 1 {
|
||||
duplicateMessages = append(duplicateMessages, fmt.Sprintf("Duplicate chat ids: %s", id))
|
||||
}
|
||||
}
|
||||
return uniqueIDs, duplicateMessages
|
||||
}
|
||||
|
||||
// BulkDeleteChats soft deletes chats owned by the current user with partial success semantics.
|
||||
func (s *ChatService) BulkDeleteChats(userID string, req *BulkDeleteChatsRequest) (map[string]interface{}, error) {
|
||||
ids := req.IDs
|
||||
if len(ids) == 0 && req.DeleteAll {
|
||||
chats, err := s.chatDAO.ListByTenantID(userID, string(entity.StatusValid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, chat := range chats {
|
||||
ids = append(ids, chat.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
uniqueIDs, duplicateMessages := checkDuplicateChatIDs(ids)
|
||||
errorsList := make([]string, 0, len(duplicateMessages))
|
||||
errorsList = append(errorsList, duplicateMessages...)
|
||||
successCount := 0
|
||||
|
||||
for _, chatID := range uniqueIDs {
|
||||
if _, err := s.getOwnedValidChat(userID, chatID); err != nil {
|
||||
errorsList = append(errorsList, fmt.Sprintf("Chat(%s) not found.", chatID))
|
||||
continue
|
||||
}
|
||||
if err := s.chatDAO.UpdateByID(chatID, map[string]interface{}{
|
||||
"status": string(entity.StatusInvalid),
|
||||
}); err != nil {
|
||||
errorsList = append(errorsList, fmt.Sprintf("Failed to delete chat %s", chatID))
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
if len(errorsList) == 0 {
|
||||
return map[string]interface{}{"success_count": successCount}, nil
|
||||
}
|
||||
if successCount > 0 {
|
||||
return map[string]interface{}{
|
||||
"success_count": successCount,
|
||||
"errors": errorsList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errors.New(strings.Join(errorsList, "; "))
|
||||
}
|
||||
|
||||
// RemoveChats removes dialogs by setting their status to invalid (soft delete)
|
||||
// Only the owner of the chat can perform this operation
|
||||
func (s *ChatService) RemoveChats(userID string, chatIDs []string) error {
|
||||
|
||||
146
internal/service/chat_delete_test.go
Normal file
146
internal/service/chat_delete_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func setupChatDeleteServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
TranslateError: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&entity.Chat{}); err != nil {
|
||||
t.Fatalf("failed to migrate test schema: %v", err)
|
||||
}
|
||||
|
||||
origDB := dao.DB
|
||||
dao.DB = db
|
||||
t.Cleanup(func() {
|
||||
dao.DB = origDB
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func createChatDeleteServiceTestChat(t *testing.T, db *gorm.DB, id, tenantID string) {
|
||||
t.Helper()
|
||||
|
||||
name := "chat-" + id
|
||||
status := string(entity.StatusValid)
|
||||
chat := &entity.Chat{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: &name,
|
||||
LLMID: "model-a",
|
||||
LLMSetting: entity.JSONMap{},
|
||||
PromptType: "simple",
|
||||
PromptConfig: entity.JSONMap{"system": "sys"},
|
||||
KBIDs: entity.JSONSlice{},
|
||||
Status: &status,
|
||||
}
|
||||
if err := db.Create(chat).Error; err != nil {
|
||||
t.Fatalf("failed to create chat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceDeleteChatRejectsNonOwner(t *testing.T) {
|
||||
db := setupChatDeleteServiceTestDB(t)
|
||||
createChatDeleteServiceTestChat(t, db, "chat-1", "tenant-1")
|
||||
|
||||
svc := NewChatService()
|
||||
err := svc.DeleteChat("user-1", "chat-1")
|
||||
if err == nil || err.Error() != "no authorization" {
|
||||
t.Fatalf("expected no authorization, got %v", err)
|
||||
}
|
||||
|
||||
chat, getErr := svc.chatDAO.GetByID("chat-1")
|
||||
if getErr != nil {
|
||||
t.Fatalf("failed to fetch chat: %v", getErr)
|
||||
}
|
||||
if chat.Status == nil || *chat.Status != string(entity.StatusValid) {
|
||||
t.Fatalf("expected chat status to remain valid, got %+v", chat.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceBulkDeleteChatsDeleteAllOnlyDeletesOwnedChats(t *testing.T) {
|
||||
db := setupChatDeleteServiceTestDB(t)
|
||||
createChatDeleteServiceTestChat(t, db, "chat-1", "user-1")
|
||||
createChatDeleteServiceTestChat(t, db, "chat-2", "user-1")
|
||||
createChatDeleteServiceTestChat(t, db, "chat-3", "tenant-2")
|
||||
|
||||
svc := NewChatService()
|
||||
result, err := svc.BulkDeleteChats("user-1", &BulkDeleteChatsRequest{DeleteAll: true})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDeleteChats failed: %v", err)
|
||||
}
|
||||
|
||||
if got, ok := result["success_count"].(int); !ok || got != 2 {
|
||||
t.Fatalf("expected success_count 2, got %+v", result["success_count"])
|
||||
}
|
||||
|
||||
owned1, err := svc.chatDAO.GetByID("chat-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch chat-1: %v", err)
|
||||
}
|
||||
if owned1.Status == nil || *owned1.Status != string(entity.StatusInvalid) {
|
||||
t.Fatalf("expected chat-1 invalid, got %+v", owned1.Status)
|
||||
}
|
||||
|
||||
owned2, err := svc.chatDAO.GetByID("chat-2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch chat-2: %v", err)
|
||||
}
|
||||
if owned2.Status == nil || *owned2.Status != string(entity.StatusInvalid) {
|
||||
t.Fatalf("expected chat-2 invalid, got %+v", owned2.Status)
|
||||
}
|
||||
|
||||
other, err := svc.chatDAO.GetByID("chat-3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch chat-3: %v", err)
|
||||
}
|
||||
if other.Status == nil || *other.Status != string(entity.StatusValid) {
|
||||
t.Fatalf("expected chat-3 to remain valid, got %+v", other.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatServiceBulkDeleteChatsReturnsPartialSuccessErrors(t *testing.T) {
|
||||
db := setupChatDeleteServiceTestDB(t)
|
||||
createChatDeleteServiceTestChat(t, db, "chat-1", "user-1")
|
||||
createChatDeleteServiceTestChat(t, db, "chat-2", "tenant-2")
|
||||
|
||||
svc := NewChatService()
|
||||
result, err := svc.BulkDeleteChats("user-1", &BulkDeleteChatsRequest{
|
||||
IDs: []string{"chat-1", "chat-2", "chat-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDeleteChats failed: %v", err)
|
||||
}
|
||||
|
||||
if got, ok := result["success_count"].(int); !ok || got != 1 {
|
||||
t.Fatalf("expected success_count 1, got %+v", result["success_count"])
|
||||
}
|
||||
|
||||
errorsList, ok := result["errors"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected errors list, got %+v", result["errors"])
|
||||
}
|
||||
joined := strings.Join(errorsList, " | ")
|
||||
if !strings.Contains(joined, "Duplicate chat ids: chat-1") {
|
||||
t.Fatalf("expected duplicate id error, got %v", errorsList)
|
||||
}
|
||||
if !strings.Contains(joined, "Chat(chat-2) not found.") {
|
||||
t.Fatalf("expected not found error, got %v", errorsList)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user