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 <tryeverypossible@163.com>
This commit is contained in:
Hz_
2026-06-22 18:16:15 +08:00
committed by GitHub
parent 02cc1d6438
commit 4e0db3053d
7 changed files with 1056 additions and 2 deletions

View File

@@ -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()
}

View File

@@ -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)
}
}