diff --git a/internal/dao/chat_channel.go b/internal/dao/chat_channel.go new file mode 100644 index 000000000..7ceeda82c --- /dev/null +++ b/internal/dao/chat_channel.go @@ -0,0 +1,43 @@ +package dao + +import "ragflow/internal/entity" + +type ChatChannelDAO struct{} + +func NewChatChannel() *ChatChannelDAO { + return &ChatChannelDAO{} +} + +func (dao *ChatChannelDAO) Create(channel *entity.ChatChannel) error { + return DB.Create(channel).Error +} + +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 + if err != nil { + return nil, err + } + return &channel, err +} + +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 +} + +func (dao *ChatChannelDAO) DeleteByID(id string, tenantID string) error { + return DB.Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&entity.ChatChannel{}).Error +} + +func (dao *ChatChannelDAO) ListByTenantID(tenantID string) ([]*entity.ChatChannelListResponse, error) { + results := make([]*entity.ChatChannelListResponse, 0) + + err := DB.Table("chat_channel"). + Select("chat_channel.id, chat_channel.name, chat_channel.channel, chat_channel.chat_id, chat_channel.status, dialog.name as dialog_name"). + Joins("LEFT JOIN dialog ON dialog.id = chat_channel.chat_id"). + Where("chat_channel.tenant_id = ?", tenantID). + Order("chat_channel.create_time DESC"). + Scan(&results).Error + + return results, err +} diff --git a/internal/dao/chat_channel_test.go b/internal/dao/chat_channel_test.go new file mode 100644 index 000000000..14fe46d6f --- /dev/null +++ b/internal/dao/chat_channel_test.go @@ -0,0 +1,237 @@ +// +// 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 dao + +import ( + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "ragflow/internal/entity" +) + +// setupChatChannelTestDB initializes an in-memory SQLite database for ChatChannel DAO tests. +func setupChatChannelTestDB(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) + } + + // Migrate chat_channel and dialog (entity.Chat) tables + if err := db.AutoMigrate(&entity.ChatChannel{}, &entity.Chat{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + return db +} + +func TestChatChannelDAO_CRUD(t *testing.T) { + db := setupChatChannelTestDB(t) + pushDB(t, db) + dao := NewChatChannel() + + // 1. Test Create + channelConfig := entity.JSONMap{ + "credential": map[string]interface{}{ + "corp_id": "ww123456", + "agent_id": float64(1000001), + "secret": "sec-key", + }, + } + statusActive := 1 + cc := &entity.ChatChannel{ + ID: "chan-1", + TenantID: "tenant-1", + Name: "Test WeCom Bot", + Channel: "wecom", + Config: channelConfig, + Status: statusActive, + } + + err := dao.Create(cc) + if err != nil { + t.Fatalf("failed to create chat channel: %v", err) + } + + // 2. Test GetByID + res, err := dao.GetByID("chan-1", "tenant-1") + if err != nil { + t.Fatalf("failed to get chat channel: %v", err) + } + if res.Name != "Test WeCom Bot" { + t.Fatalf("expected Name %q, got %q", "Test WeCom Bot", res.Name) + } + cred, ok := res.Config["credential"].(map[string]interface{}) + if !ok { + t.Fatalf("failed to parse config credentials") + } + if cred["corp_id"] != "ww123456" { + t.Fatalf("expected corp_id %q, got %v", "ww123456", cred["corp_id"]) + } + + // 2b. Test tenant isolation for GetByID + _, err = dao.GetByID("chan-1", "tenant-2") + if err == nil { + t.Fatalf("expected error (not found) when getting with wrong tenant, got nil") + } + + // 3. Test UpdateByID + updates := map[string]interface{}{ + "name": "Updated WeCom Bot", + } + // Try updating with wrong tenant + err = dao.UpdateByID("chan-1", "tenant-2", updates) + if err != nil { + t.Fatalf("failed to run UpdateByID with wrong tenant: %v", err) + } + // Verify it was NOT updated (should still be "Test WeCom Bot" since wrong tenant was used) + res, err = dao.GetByID("chan-1", "tenant-1") + if err != nil { + t.Fatalf("failed to get chat channel: %v", err) + } + if res.Name != "Test WeCom Bot" { + t.Fatalf("expected Name to remain %q, but got %q", "Test WeCom Bot", res.Name) + } + + // Update with correct tenant + err = dao.UpdateByID("chan-1", "tenant-1", updates) + if err != nil { + t.Fatalf("failed to update chat channel: %v", err) + } + + res, err = dao.GetByID("chan-1", "tenant-1") + if err != nil { + t.Fatalf("failed to get updated chat channel: %v", err) + } + if res.Name != "Updated WeCom Bot" { + t.Fatalf("expected updated Name %q, got %q", "Updated WeCom Bot", res.Name) + } + + // 3b. Test DeleteByID with wrong tenant (should not delete) + err = dao.DeleteByID("chan-1", "tenant-2") + if err != nil { + t.Fatalf("failed to delete with wrong tenant: %v", err) + } + // Verify it still exists for tenant-1 + _, err = dao.GetByID("chan-1", "tenant-1") + if err != nil { + t.Fatalf("expected chat channel to still exist for tenant-1, got error: %v", err) + } + + // 4. Test DeleteByID + err = dao.DeleteByID("chan-1", "tenant-1") + if err != nil { + t.Fatalf("failed to delete chat channel: %v", err) + } + + _, err = dao.GetByID("chan-1", "tenant-1") + if err == nil { + t.Fatalf("expected record not found error, got nil") + } +} + +func TestChatChannelDAO_ListByTenantID(t *testing.T) { + db := setupChatChannelTestDB(t) + pushDB(t, db) + dao := NewChatChannel() + + // Create a test dialog (chat assistant) + dialogName := "Dialog Assistant A" + d := &entity.Chat{ + ID: "diag-1", + TenantID: "tenant-1", + Name: &dialogName, + LLMID: "model-a", + LLMSetting: entity.JSONMap{"temp": 0.1}, + PromptConfig: entity.JSONMap{"system": "sys"}, + KBIDs: entity.JSONSlice{"kb-1"}, + } + if err := db.Create(d).Error; err != nil { + t.Fatalf("failed to create test dialog: %v", err) + } + + statusActive := 1 + now := time.Now().UnixMilli() + t1 := now - 1000 + t2 := now + + // Create chat channels (order: oldest first, so list should return youngest first) + cc1 := &entity.ChatChannel{ + ID: "chan-1", + TenantID: "tenant-1", + Name: "WeCom Channel 1", + Channel: "wecom", + Config: entity.JSONMap{"cred": "A"}, + ChatID: &d.ID, + Status: statusActive, + BaseModel: entity.BaseModel{ + CreateTime: &t1, + }, + } + cc2 := &entity.ChatChannel{ + ID: "chan-2", + TenantID: "tenant-1", + Name: "WeCom Channel 2", + Channel: "wecom", + Config: entity.JSONMap{"cred": "B"}, + ChatID: &d.ID, + Status: statusActive, + BaseModel: entity.BaseModel{ + CreateTime: &t2, + }, + } + + if err := db.Create(cc1).Error; err != nil { + t.Fatalf("failed to create cc1: %v", err) + } + if err := db.Create(cc2).Error; err != nil { + t.Fatalf("failed to create cc2: %v", err) + } + + // Perform query + list, err := dao.ListByTenantID("tenant-1") + if err != nil { + t.Fatalf("ListByTenantID failed: %v", err) + } + + // Verify count and order (create_time DESC, so cc2 should be index 0) + if len(list) != 2 { + t.Fatalf("expected 2 items, got %d", len(list)) + } + + // cc2 is first because t2 > t1 + if list[0].ID != "chan-2" { + t.Fatalf("expected first item ID %q, got %q (ordering failed)", "chan-2", list[0].ID) + } + if list[1].ID != "chan-1" { + t.Fatalf("expected second item ID %q, got %q", "chan-1", list[1].ID) + } + if list[0].ChatID == nil || *list[0].ChatID != d.ID { + t.Fatalf("expected chat_id %q, got %v", d.ID, list[0].ChatID) + } + + // Verify Left Join mapping + if list[0].DialogName == nil || *list[0].DialogName != "Dialog Assistant A" { + t.Fatalf("expected dialog name %q, got %v (left join failed)", "Dialog Assistant A", list[0].DialogName) + } +} diff --git a/internal/entity/chat_channel.go b/internal/entity/chat_channel.go new file mode 100644 index 000000000..3e71a426e --- /dev/null +++ b/internal/entity/chat_channel.go @@ -0,0 +1,43 @@ +// +// 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 entity + +// ChatChannel chat channel model +type ChatChannel struct { + ID string `gorm:"column:id;primaryKey;size:32" json:"id"` + TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"` + Name string `gorm:"column:name;size:128;not null" json:"name"` + Channel string `gorm:"column:channel;size:128;not null;index" json:"channel"` + Config JSONMap `gorm:"column:config;type:longtext;not null" json:"config"` + ChatID *string `gorm:"column:chat_id;size:32;index" json:"chat_id,omitempty"` + Status int `gorm:"column:status;index;default:1" json:"status,omitempty"` + BaseModel +} + +// TableName specify table name +func (ChatChannel) TableName() string { + return "chat_channel" +} + +type ChatChannelListResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Channel string `json:"channel"` + ChatID *string `json:"chat_id"` + Status int `json:"status"` + DialogName *string `json:"dialog_name"` // Dialog.name.alias("dialog_name") +}