From 4e0db3053df3ce5ba9eea8ce9ac9de8c8f00bccf Mon Sep 17 00:00:00 2001 From: Hz_ Date: Mon, 22 Jun 2026 18:16:15 +0800 Subject: [PATCH] feat(go-api): complete chat channel API migration with tests (#16139) close #16132 ## Summary This PR completes the Go-side merge and cleanup for chat channel APIs, including handler/service wiring, route registration, and test coverage. Implemented and aligned 5 chat channel APIs: ``` - POST `/api/v1/chat-channels` - GET `/api/v1/chat-channels` - GET `/api/v1/chat-channels/:channel_id` - PATCH `/api/v1/chat-channels/:channel_id` - DELETE `/api/v1/chat-channels/:channel_id` ``` Co-authored-by: Haruko386 --- cmd/server_main.go | 5 +- internal/dao/chat_channel.go | 12 + internal/handler/chat_channel.go | 229 ++++++++++++++++++ internal/handler/chat_channel_test.go | 328 ++++++++++++++++++++++++++ internal/router/router.go | 13 + internal/service/chat_channel.go | 233 ++++++++++++++++++ internal/service/chat_channel_test.go | 238 +++++++++++++++++++ 7 files changed, 1056 insertions(+), 2 deletions(-) create mode 100644 internal/handler/chat_channel.go create mode 100644 internal/handler/chat_channel_test.go create mode 100644 internal/service/chat_channel.go create mode 100644 internal/service/chat_channel_test.go diff --git a/cmd/server_main.go b/cmd/server_main.go index da7753f31b..03304b9bd0 100644 --- a/cmd/server_main.go +++ b/cmd/server_main.go @@ -212,6 +212,7 @@ func startServer(config *server.Config) { llmService := service.NewLLMService() tenantService := service.NewTenantService() chatService := service.NewChatService() + chatChannelService := service.NewChatChannelService() chatSessionService := service.NewChatSessionService() openaiChatService := service.NewOpenAIChatService() systemService := service.NewSystemService() @@ -236,6 +237,7 @@ func startServer(config *server.Config) { chunkHandler := handler.NewChunkHandler(chunkService, userService) llmHandler := handler.NewLLMHandler(llmService, userService) chatHandler := handler.NewChatHandler(chatService, userService) + chatChannelHandler := handler.NewChatChannelHandler(chatChannelService) chatSessionHandler := handler.NewChatSessionHandler(chatSessionService, userService) openaiChatHandler := handler.NewOpenAIChatHandler(openaiChatService) connectorHandler := handler.NewConnectorHandler(connectorService, userService) @@ -292,7 +294,6 @@ func startServer(config *server.Config) { docDAO, docEngine, ) - // Per-tenant canvas-runtime override selector, backed by the // existing Redis client and the global logger. The handler is // ALWAYS constructed, even when Redis is briefly unavailable at @@ -310,7 +311,7 @@ func startServer(config *server.Config) { adminRuntimeHandler := handler.NewAdminRuntimeHandler(adminRuntimeSelector) // Initialize router - r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler, searchBotHandler, difyRetrievalHandler, pluginHandler, modelHandler, fileCommitHandler, adminRuntimeHandler, openaiChatHandler) + r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatChannelHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler, searchBotHandler, difyRetrievalHandler, pluginHandler, modelHandler, fileCommitHandler, adminRuntimeHandler, openaiChatHandler) // Create Gin engine ginEngine := gin.New() diff --git a/internal/dao/chat_channel.go b/internal/dao/chat_channel.go index 7ceeda82c2..484a31b121 100644 --- a/internal/dao/chat_channel.go +++ b/internal/dao/chat_channel.go @@ -12,6 +12,15 @@ func (dao *ChatChannelDAO) Create(channel *entity.ChatChannel) error { return DB.Create(channel).Error } +func (dao *ChatChannelDAO) GetByIDOnly(id string) (*entity.ChatChannel, error) { + var channel entity.ChatChannel + err := DB.Where("id = ?", id).First(&channel).Error + if err != nil { + return nil, err + } + return &channel, err +} + func (dao *ChatChannelDAO) GetByID(id string, tenantID string) (*entity.ChatChannel, error) { var channel entity.ChatChannel err := DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&channel).Error @@ -21,14 +30,17 @@ func (dao *ChatChannelDAO) GetByID(id string, tenantID string) (*entity.ChatChan return &channel, err } +// UpdateByID Update a single record by ID func (dao *ChatChannelDAO) UpdateByID(id string, tenantID string, updates map[string]any) error { return DB.Model(&entity.ChatChannel{}).Where("id = ? AND tenant_id = ?", id, tenantID).Updates(updates).Error } +// DeleteByID Delete a single record by ID func (dao *ChatChannelDAO) DeleteByID(id string, tenantID string) error { return DB.Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&entity.ChatChannel{}).Error } +// ListByTenantID List a single record by TenantID func (dao *ChatChannelDAO) ListByTenantID(tenantID string) ([]*entity.ChatChannelListResponse, error) { results := make([]*entity.ChatChannelListResponse, 0) diff --git a/internal/handler/chat_channel.go b/internal/handler/chat_channel.go new file mode 100644 index 0000000000..4417685996 --- /dev/null +++ b/internal/handler/chat_channel.go @@ -0,0 +1,229 @@ +// 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 handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/entity" + "ragflow/internal/service" +) + +type ChatChannelService interface { + CreateChatChannel(tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) + List(tenantID string) ([]*entity.ChatChannelListResponse, error) + GetChatChannel(userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) + UpdateChatChannel(userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error) + DeleteChatChannel(userID, channelID string) (bool, common.ErrorCode, error) +} + +type ChatChannelHandler struct { + chatChannelService ChatChannelService +} + +func NewChatChannelHandler(chatChannelService ChatChannelService) *ChatChannelHandler { + return &ChatChannelHandler{chatChannelService: chatChannelService} +} + +// NewChatChannel keeps the existing constructor shape used by boot code. +func NewChatChannel() *ChatChannelHandler { + return NewChatChannelHandler(service.NewChatChannelService()) +} + +type CreateChatChannelRequest struct { + Name string `json:"name" binding:"required"` + Channel string `json:"channel" binding:"required"` + Config entity.JSONMap `json:"config" binding:"required"` + ChatID *string `json:"chat_id"` +} + +// CreateChatChannel handles POST /chat-channels. +func (h *ChatChannelHandler) CreateChatChannel(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + var req CreateChatChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + jsonError(c, common.CodeDataError, "Invalid request: "+err.Error()) + return + } + + row, err := h.chatChannelService.CreateChatChannel( + user.ID, + req.Name, + req.Channel, + req.Config, + req.ChatID, + ) + if err != nil { + jsonError(c, common.CodeServerError, err.Error()) + return + } + jsonResponse(c, common.CodeSuccess, row, "success") +} + +// ListChatChannel handles GET /chat-channels. +func (h *ChatChannelHandler) ListChatChannel(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + rows, err := h.chatChannelService.List(user.ID) + if err != nil { + jsonError(c, common.CodeServerError, err.Error()) + return + } + jsonResponse(c, common.CodeSuccess, rows, "success") +} + +// GetChatChannel handles GET /chat-channels/:channel_id. +func (h *ChatChannelHandler) GetChatChannel(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + userID := strings.TrimSpace(user.ID) + if userID == "" { + jsonError(c, common.CodeArgumentError, "user_id is required") + return + } + + channelID := strings.TrimSpace(c.Param("channel_id")) + if channelID == "" { + jsonError(c, common.CodeArgumentError, "channel_id is required") + return + } + + channel, code, err := h.chatChannelService.GetChatChannel(userID, channelID) + if code != common.CodeSuccess || err != nil { + writeChatChannelError(c, code, chatChannelErrMsg(code, err)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": channel, + "message": "success", + }) +} + +// UpdateChatChannel handles PATCH /chat-channels/:channel_id. +func (h *ChatChannelHandler) UpdateChatChannel(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + userID := strings.TrimSpace(user.ID) + if userID == "" { + jsonError(c, common.CodeArgumentError, "user_id is required") + return + } + + channelID := strings.TrimSpace(c.Param("channel_id")) + if channelID == "" { + jsonError(c, common.CodeArgumentError, "channel_id is required") + return + } + + var request map[string]interface{} + if err := c.ShouldBindJSON(&request); err != nil { + jsonError(c, common.CodeDataError, err.Error()) + return + } + + result, code, err := h.chatChannelService.UpdateChatChannel(userID, channelID, unwrapChatChannelPayload(request)) + if code != common.CodeSuccess || err != nil { + writeChatChannelError(c, code, chatChannelErrMsg(code, err)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": result, + "message": "success", + }) +} + +// DeleteChatChannel handles DELETE /chat-channels/:channel_id. +func (h *ChatChannelHandler) DeleteChatChannel(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + userID := strings.TrimSpace(user.ID) + if userID == "" { + jsonError(c, common.CodeArgumentError, "user_id is required") + return + } + + channelID := strings.TrimSpace(c.Param("channel_id")) + if channelID == "" { + jsonError(c, common.CodeArgumentError, "channel_id is required") + return + } + + result, code, err := h.chatChannelService.DeleteChatChannel(userID, channelID) + if code != common.CodeSuccess || err != nil { + writeChatChannelError(c, code, chatChannelErrMsg(code, err)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": result, + "message": "success", + }) +} + +func unwrapChatChannelPayload(payload map[string]interface{}) map[string]interface{} { + if data, ok := payload["data"].(map[string]interface{}); ok { + return data + } + return payload +} + +func writeChatChannelError(c *gin.Context, code common.ErrorCode, message string) { + if code == common.CodeAuthenticationError && message == "No authorization." { + c.JSON(http.StatusOK, gin.H{ + "code": code, + "data": false, + "message": message, + }) + return + } + jsonError(c, code, message) +} + +func chatChannelErrMsg(code common.ErrorCode, err error) string { + if err != nil { + return err.Error() + } + return code.Message() +} diff --git a/internal/handler/chat_channel_test.go b/internal/handler/chat_channel_test.go new file mode 100644 index 0000000000..da68cb4a9a --- /dev/null +++ b/internal/handler/chat_channel_test.go @@ -0,0 +1,328 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/entity" +) + +type fakeChatChannelService struct { + createFn func(tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) + listFn func(tenantID string) ([]*entity.ChatChannelListResponse, error) + getFn func(userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) + updateFn func(userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error) + deleteFn func(userID, channelID string) (bool, common.ErrorCode, error) +} + +func (f fakeChatChannelService) CreateChatChannel(tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) { + if f.createFn == nil { + return nil, errors.New("unexpected CreateChatChannel call") + } + return f.createFn(tenantID, name, channelType, config, chatID) +} + +func (f fakeChatChannelService) List(tenantID string) ([]*entity.ChatChannelListResponse, error) { + if f.listFn == nil { + return nil, errors.New("unexpected List call") + } + return f.listFn(tenantID) +} + +func (f fakeChatChannelService) GetChatChannel(userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) { + if f.getFn == nil { + return nil, common.CodeServerError, errors.New("unexpected GetChatChannel call") + } + return f.getFn(userID, channelID) +} + +func (f fakeChatChannelService) UpdateChatChannel(userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error) { + if f.updateFn == nil { + return nil, common.CodeServerError, errors.New("unexpected UpdateChatChannel call") + } + return f.updateFn(userID, channelID, req) +} + +func (f fakeChatChannelService) DeleteChatChannel(userID, channelID string) (bool, common.ErrorCode, error) { + if f.deleteFn == nil { + return false, common.CodeServerError, errors.New("unexpected DeleteChatChannel call") + } + return f.deleteFn(userID, channelID) +} + +func TestChatChannelHandlerCreateSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotTenantID, gotName, gotChannel string + var gotConfig entity.JSONMap + var gotChatID *string + + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + createFn: func(tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) { + gotTenantID = tenantID + gotName = name + gotChannel = channelType + gotConfig = config + gotChatID = chatID + return &entity.ChatChannel{ + ID: "cc-1", + TenantID: tenantID, + Name: name, + Channel: channelType, + Config: config, + ChatID: chatID, + Status: 1, + }, nil + }, + }, + } + + router := gin.New() + router.POST("/api/v1/chat-channels", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.CreateChatChannel(c) + }) + + body := `{"name":"bot-a","channel":"dingtalk","config":{"token":"abc"},"chat_id":"dialog-1"}` + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat-channels", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) + } + + if gotTenantID != "tenant-1" || gotName != "bot-a" || gotChannel != "dingtalk" { + t.Fatalf("service args tenant=%q name=%q channel=%q", gotTenantID, gotName, gotChannel) + } + if gotConfig["token"] != "abc" { + t.Fatalf("config=%v", gotConfig) + } + if gotChatID == nil || *gotChatID != "dialog-1" { + t.Fatalf("chatID=%v", gotChatID) + } + + var payload map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload["code"] != float64(common.CodeSuccess) { + t.Fatalf("payload=%v", payload) + } +} + +func TestChatChannelHandlerCreateInvalidRequestStopsEarly(t *testing.T) { + gin.SetMode(gin.TestMode) + + called := false + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + createFn: func(tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) { + called = true + return nil, nil + }, + }, + } + + router := gin.New() + router.POST("/api/v1/chat-channels", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.CreateChatChannel(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat-channels", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(resp, req) + + if called { + t.Fatal("service should not be called when request binding fails") + } + + var payload map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload["code"] != float64(common.CodeDataError) { + t.Fatalf("payload=%v", payload) + } +} + +func TestChatChannelHandlerListSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + + gotTenantID := "" + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + listFn: func(tenantID string) ([]*entity.ChatChannelListResponse, error) { + gotTenantID = tenantID + return []*entity.ChatChannelListResponse{ + {ID: "cc-1", Name: "bot-a", Channel: "dingtalk", Status: 1}, + }, nil + }, + }, + } + + router := gin.New() + router.GET("/api/v1/chat-channels", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.ListChatChannel(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat-channels", nil) + router.ServeHTTP(resp, req) + + if gotTenantID != "tenant-1" { + t.Fatalf("tenantID=%q", gotTenantID) + } + + var payload map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload["code"] != float64(common.CodeSuccess) { + t.Fatalf("payload=%v", payload) + } +} + +func TestChatChannelHandlerListServiceError(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + listFn: func(tenantID string) ([]*entity.ChatChannelListResponse, error) { + return nil, errors.New("db failed") + }, + }, + } + + router := gin.New() + router.GET("/api/v1/chat-channels", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.ListChatChannel(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat-channels", nil) + router.ServeHTTP(resp, req) + + var payload map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload["code"] != float64(common.CodeServerError) { + t.Fatalf("payload=%v", payload) + } +} + +func TestChatChannelHandlerGetChatChannelUnauthorized(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + getFn: func(userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) { + return nil, common.CodeAuthenticationError, errors.New("No authorization.") + }, + }, + } + + router := gin.New() + router.GET("/api/v1/chat-channels/:channel_id", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.GetChatChannel(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat-channels/cc-1", nil) + router.ServeHTTP(resp, req) + + var payload map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload["code"] != float64(common.CodeAuthenticationError) { + t.Fatalf("payload=%v", payload) + } + if payload["data"] != false { + t.Fatalf("payload=%v", payload) + } +} + +func TestChatChannelHandlerUpdateChatChannelUnwrapsData(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotReq map[string]interface{} + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + updateFn: func(userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error) { + gotReq = req + return &entity.ChatChannel{ID: channelID, TenantID: userID, Name: "new-name"}, common.CodeSuccess, nil + }, + }, + } + + router := gin.New() + router.PATCH("/api/v1/chat-channels/:channel_id", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.UpdateChatChannel(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/v1/chat-channels/cc-1", strings.NewReader(`{"data":{"name":"new-name"}}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(resp, req) + + if gotReq["name"] != "new-name" { + t.Fatalf("req=%v", gotReq) + } +} + +func TestChatChannelHandlerDeleteChatChannelSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotUserID, gotChannelID string + h := &ChatChannelHandler{ + chatChannelService: fakeChatChannelService{ + deleteFn: func(userID, channelID string) (bool, common.ErrorCode, error) { + gotUserID = userID + gotChannelID = channelID + return true, common.CodeSuccess, nil + }, + }, + } + + router := gin.New() + router.DELETE("/api/v1/chat-channels/:channel_id", func(c *gin.Context) { + c.Set("user", &entity.User{ID: "tenant-1"}) + h.DeleteChatChannel(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/chat-channels/cc-1", nil) + router.ServeHTTP(resp, req) + + if gotUserID != "tenant-1" || gotChannelID != "cc-1" { + t.Fatalf("userID=%q channelID=%q", gotUserID, gotChannelID) + } + + var payload map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload["code"] != float64(common.CodeSuccess) { + t.Fatalf("payload=%v", payload) + } + if payload["data"] != true { + t.Fatalf("payload=%v", payload) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index a3ba05992d..c77263701b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -33,6 +33,7 @@ type Router struct { chunkHandler *handler.ChunkHandler llmHandler *handler.LLMHandler chatHandler *handler.ChatHandler + chatChannelHandler *handler.ChatChannelHandler openaiChatHandler *handler.OpenAIChatHandler chatSessionHandler *handler.ChatSessionHandler connectorHandler *handler.ConnectorHandler @@ -63,6 +64,7 @@ func NewRouter( chunkHandler *handler.ChunkHandler, llmHandler *handler.LLMHandler, chatHandler *handler.ChatHandler, + chatChannelHandler *handler.ChatChannelHandler, chatSessionHandler *handler.ChatSessionHandler, connectorHandler *handler.ConnectorHandler, searchHandler *handler.SearchHandler, @@ -91,6 +93,7 @@ func NewRouter( chunkHandler: chunkHandler, llmHandler: llmHandler, chatHandler: chatHandler, + chatChannelHandler: chatChannelHandler, openaiChatHandler: openaiChatHandler, chatSessionHandler: chatSessionHandler, connectorHandler: connectorHandler, @@ -608,6 +611,16 @@ func (r *Router) Setup(engine *gin.Engine) { chat.POST("/rm", r.chatHandler.RemoveChats) } + // Chat Channel + chanChannel := v1.Group("/chat-channels") + { + chanChannel.POST("", r.chatChannelHandler.CreateChatChannel) + chanChannel.GET("", r.chatChannelHandler.ListChatChannel) + chanChannel.GET("/:channel_id", r.chatChannelHandler.GetChatChannel) + chanChannel.PATCH("/:channel_id", r.chatChannelHandler.UpdateChatChannel) + chanChannel.DELETE("/:channel_id", r.chatChannelHandler.DeleteChatChannel) + } + // Chat session (conversation) routes session := authorized.Group("/v1/conversation") { diff --git a/internal/service/chat_channel.go b/internal/service/chat_channel.go new file mode 100644 index 0000000000..7f910c3060 --- /dev/null +++ b/internal/service/chat_channel.go @@ -0,0 +1,233 @@ +// 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 ( + "errors" + "fmt" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" +) + +type ChatChannelService struct { + chatChannelDAO *dao.ChatChannelDAO + chatDAO *dao.ChatDAO + userTenantDAO *dao.UserTenantDAO +} + +func NewChatChannelService() *ChatChannelService { + return &ChatChannelService{ + chatChannelDAO: dao.NewChatChannel(), + chatDAO: dao.NewChatDAO(), + userTenantDAO: dao.NewUserTenantDAO(), + } +} + +func (s *ChatChannelService) Insert(channel *entity.ChatChannel) error { + if channel == nil { + return errors.New("channel is nil") + } + if channel.ID == "" { + channel.ID = common.GenerateUUID() + } + if channel.Status == 0 { + channel.Status = 1 + } + return s.chatChannelDAO.Create(channel) +} + +func (s *ChatChannelService) GetByID(id string) (*entity.ChatChannel, error) { + if id == "" { + return nil, errors.New("id is empty") + } + return s.chatChannelDAO.GetByIDOnly(id) +} + +func (s *ChatChannelService) List(tenantID string) ([]*entity.ChatChannelListResponse, error) { + return s.chatChannelDAO.ListByTenantID(tenantID) +} + +func (s *ChatChannelService) CreateChatChannel(tenantID, name, channelType string, config entity.JSONMap, chatID *string) (*entity.ChatChannel, error) { + if chatID != nil && *chatID != "" { + dialog, err := s.chatDAO.GetByID(*chatID) + if err != nil { + if dao.IsNotFoundErr(err) { + return nil, errors.New("Can't find this chat assistant!") + } + return nil, err + } + if dialog.TenantID != tenantID { + return nil, errors.New("No authorization.") + } + } + row := &entity.ChatChannel{ + ID: common.GenerateUUID(), + TenantID: tenantID, + Name: name, + Channel: channelType, + Config: config, + ChatID: chatID, + Status: 1, + } + + if err := s.Insert(row); err != nil { + return nil, err + } + + created, err := s.GetByID(row.ID) + if err != nil { + return nil, fmt.Errorf("failed to load created chat channel: %w", err) + } + return created, nil +} + +func (s *ChatChannelService) accessible(userID, channelID string) (*entity.ChatChannel, bool, error) { + channel, err := s.chatChannelDAO.GetByIDOnly(channelID) + if err != nil { + if dao.IsNotFoundErr(err) { + return nil, false, nil + } + return nil, false, err + } + + if channel.TenantID == userID { + return channel, true, nil + } + + tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) + if err != nil { + return nil, false, err + } + for _, tenantID := range tenantIDs { + if tenantID == channel.TenantID { + return channel, true, nil + } + } + + return channel, false, nil +} + +func (s *ChatChannelService) GetChatChannel(userID, channelID string) (*entity.ChatChannel, common.ErrorCode, error) { + _, ok, err := s.accessible(userID, channelID) + if err != nil { + return nil, common.CodeServerError, err + } + if !ok { + return nil, common.CodeAuthenticationError, errors.New("No authorization.") + } + + channel, err := s.chatChannelDAO.GetByIDOnly(channelID) + if err != nil { + if dao.IsNotFoundErr(err) { + return nil, common.CodeDataError, errors.New("Can't find this chat channel!") + } + return nil, common.CodeServerError, err + } + return channel, common.CodeSuccess, nil +} + +func (s *ChatChannelService) UpdateChatChannel(userID, channelID string, req map[string]interface{}) (*entity.ChatChannel, common.ErrorCode, error) { + channel, ok, err := s.accessible(userID, channelID) + if err != nil { + return nil, common.CodeServerError, err + } + if !ok { + return nil, common.CodeAuthenticationError, errors.New("No authorization.") + } + if channel == nil { + return nil, common.CodeDataError, errors.New("Can't find this chat channel!") + } + + updates := map[string]interface{}{} + + if value, exists := req["name"]; exists { + name, ok := value.(string) + if !ok { + return nil, common.CodeDataError, errors.New("name must be string") + } + updates["name"] = name + } + + if value, exists := req["config"]; exists { + if value == nil { + updates["config"] = nil + } else { + config, ok := value.(map[string]interface{}) + if !ok { + return nil, common.CodeDataError, errors.New("config must be object") + } + updates["config"] = entity.JSONMap(config) + } + } + + if value, exists := req["chat_id"]; exists { + if value == nil { + updates["chat_id"] = nil + } else { + chatID, ok := value.(string) + if !ok { + return nil, common.CodeDataError, errors.New("chat_id must be string or null") + } + if chatID != "" { + dialog, err := s.chatDAO.GetByID(chatID) + if err != nil { + if dao.IsNotFoundErr(err) { + return nil, common.CodeDataError, errors.New("Can't find this chat assistant!") + } + return nil, common.CodeServerError, err + } + if dialog.TenantID != channel.TenantID { + return nil, common.CodeAuthenticationError, errors.New("No authorization.") + } + } + updates["chat_id"] = chatID + } + } + + if len(updates) > 0 { + if err := s.chatChannelDAO.UpdateByID(channelID, channel.TenantID, updates); err != nil { + return nil, common.CodeDataError, err + } + } + + updated, err := s.chatChannelDAO.GetByIDOnly(channelID) + if err != nil { + if dao.IsNotFoundErr(err) { + return nil, common.CodeDataError, errors.New("Can't find this chat channel!") + } + return nil, common.CodeServerError, err + } + return updated, common.CodeSuccess, nil +} + +func (s *ChatChannelService) DeleteChatChannel(userID, channelID string) (bool, common.ErrorCode, error) { + channel, ok, err := s.accessible(userID, channelID) + if err != nil { + return false, common.CodeServerError, err + } + if !ok { + return false, common.CodeAuthenticationError, errors.New("No authorization.") + } + if channel == nil { + return false, common.CodeAuthenticationError, errors.New("No authorization.") + } + + if err := s.chatChannelDAO.DeleteByID(channelID, channel.TenantID); err != nil { + return false, common.CodeDataError, err + } + return true, common.CodeSuccess, nil +} diff --git a/internal/service/chat_channel_test.go b/internal/service/chat_channel_test.go new file mode 100644 index 0000000000..cc241c8745 --- /dev/null +++ b/internal/service/chat_channel_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" +) + +func setupChatChannelServiceTestDB(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.ChatChannel{}, &entity.Chat{}, &entity.UserTenant{}); 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 createServiceTestDialog(t *testing.T, db *gorm.DB, id, tenantID, name string) *entity.Chat { + t.Helper() + + dialogName := name + dialog := &entity.Chat{ + ID: id, + TenantID: tenantID, + Name: &dialogName, + LLMID: "model-a", + LLMSetting: entity.JSONMap{"temperature": 0.1}, + PromptType: "simple", + PromptConfig: entity.JSONMap{"system": "sys"}, + KBIDs: entity.JSONSlice{"kb-1"}, + } + if err := db.Create(dialog).Error; err != nil { + t.Fatalf("failed to create dialog: %v", err) + } + return dialog +} + +func createServiceTestChannel(t *testing.T, db *gorm.DB, channel *entity.ChatChannel) *entity.ChatChannel { + t.Helper() + + if err := db.Create(channel).Error; err != nil { + t.Fatalf("failed to create chat channel: %v", err) + } + return channel +} + +func createServiceTestMembership(t *testing.T, db *gorm.DB, userID, tenantID string) { + t.Helper() + + status := "1" + member := &entity.UserTenant{ + ID: userID + "_" + tenantID, + UserID: userID, + TenantID: tenantID, + Role: "normal", + Status: &status, + } + if err := db.Create(member).Error; err != nil { + t.Fatalf("failed to create tenant membership: %v", err) + } +} + +func TestChatChannelServiceCreateAndList(t *testing.T) { + db := setupChatChannelServiceTestDB(t) + createServiceTestDialog(t, db, "dialog-1", "tenant-1", "Assistant A") + + svc := NewChatChannelService() + chatID := "dialog-1" + + channel, err := svc.CreateChatChannel( + "tenant-1", + "bot-a", + "dingtalk", + entity.JSONMap{"token": "abc"}, + &chatID, + ) + if err != nil { + t.Fatalf("CreateChatChannel failed: %v", err) + } + if channel.ID == "" { + t.Fatal("expected generated id") + } + if channel.TenantID != "tenant-1" || channel.Name != "bot-a" || channel.Channel != "dingtalk" { + t.Fatalf("unexpected created channel: %+v", channel) + } + + list, err := svc.List("tenant-1") + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 channel, got %d", len(list)) + } + if list[0].DialogName == nil || *list[0].DialogName != "Assistant A" { + t.Fatalf("expected joined dialog name, got %+v", list[0]) + } +} + +func TestChatChannelServiceGetChatChannelAllowsJoinedTenant(t *testing.T) { + db := setupChatChannelServiceTestDB(t) + createServiceTestChannel(t, db, &entity.ChatChannel{ + ID: "cc-1", + TenantID: "tenant-1", + Name: "bot-a", + Channel: "wecom", + Config: entity.JSONMap{"token": "abc"}, + Status: 1, + }) + createServiceTestMembership(t, db, "user-2", "tenant-1") + + channel, code, err := NewChatChannelService().GetChatChannel("user-2", "cc-1") + if err != nil { + t.Fatalf("GetChatChannel failed: %v", err) + } + if code != common.CodeSuccess { + t.Fatalf("expected success code, got %v", code) + } + if channel == nil || channel.ID != "cc-1" { + t.Fatalf("unexpected channel: %+v", channel) + } +} + +func TestChatChannelServiceUpdateChatChannelSuccess(t *testing.T) { + db := setupChatChannelServiceTestDB(t) + createServiceTestDialog(t, db, "dialog-1", "tenant-1", "Assistant A") + createServiceTestDialog(t, db, "dialog-2", "tenant-1", "Assistant B") + chatID := "dialog-1" + createServiceTestChannel(t, db, &entity.ChatChannel{ + ID: "cc-1", + TenantID: "tenant-1", + Name: "bot-a", + Channel: "wecom", + Config: entity.JSONMap{"token": "old"}, + ChatID: &chatID, + Status: 1, + }) + + updated, code, err := NewChatChannelService().UpdateChatChannel("tenant-1", "cc-1", map[string]interface{}{ + "name": "bot-b", + "config": map[string]interface{}{"token": "new"}, + "chat_id": "dialog-2", + }) + if err != nil { + t.Fatalf("UpdateChatChannel failed: %v", err) + } + if code != common.CodeSuccess { + t.Fatalf("expected success code, got %v", code) + } + if updated.Name != "bot-b" { + t.Fatalf("expected updated name, got %+v", updated) + } + if updated.ChatID == nil || *updated.ChatID != "dialog-2" { + t.Fatalf("expected updated chat_id, got %+v", updated.ChatID) + } + if updated.Config["token"] != "new" { + t.Fatalf("expected updated config, got %+v", updated.Config) + } +} + +func TestChatChannelServiceUpdateChatChannelRejectsCrossTenantDialog(t *testing.T) { + db := setupChatChannelServiceTestDB(t) + createServiceTestDialog(t, db, "dialog-2", "tenant-2", "Assistant B") + createServiceTestChannel(t, db, &entity.ChatChannel{ + ID: "cc-1", + TenantID: "tenant-1", + Name: "bot-a", + Channel: "wecom", + Config: entity.JSONMap{"token": "old"}, + Status: 1, + }) + + _, code, err := NewChatChannelService().UpdateChatChannel("tenant-1", "cc-1", map[string]interface{}{ + "chat_id": "dialog-2", + }) + if code != common.CodeAuthenticationError { + t.Fatalf("expected authentication error, got %v", code) + } + if err == nil || !strings.Contains(err.Error(), "No authorization.") { + t.Fatalf("expected authorization error, got %v", err) + } +} + +func TestChatChannelServiceDeleteChatChannel(t *testing.T) { + db := setupChatChannelServiceTestDB(t) + createServiceTestChannel(t, db, &entity.ChatChannel{ + ID: "cc-1", + TenantID: "tenant-1", + Name: "bot-a", + Channel: "wecom", + Config: entity.JSONMap{"token": "old"}, + Status: 1, + }) + + svc := NewChatChannelService() + + deleted, code, err := svc.DeleteChatChannel("user-2", "cc-1") + if code != common.CodeAuthenticationError { + t.Fatalf("expected authentication error, got %v", code) + } + if err == nil || !strings.Contains(err.Error(), "No authorization.") { + t.Fatalf("expected authorization error, got %v", err) + } + if deleted { + t.Fatal("expected delete to be rejected") + } + + deleted, code, err = svc.DeleteChatChannel("tenant-1", "cc-1") + if err != nil { + t.Fatalf("DeleteChatChannel failed: %v", err) + } + if code != common.CodeSuccess || !deleted { + t.Fatalf("unexpected delete result: deleted=%v code=%v err=%v", deleted, code, err) + } + + if _, err := svc.GetByID("cc-1"); err == nil { + t.Fatal("expected deleted record to be gone") + } +}