refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
+84 -35
View File
@@ -1,6 +1,7 @@
package services
import (
"context"
"fmt"
"strings"
"sync"
@@ -64,8 +65,6 @@ func setupMessageWelcomeTestDB(t *testing.T) *gorm.DB {
&models.AIAgent{},
&models.Channel{},
&models.ChannelMessageOutbox{},
&models.Customer{},
&models.CustomerIdentity{},
&models.Conversation{},
&models.ConversationParticipant{},
&models.ConversationReadState{},
@@ -378,38 +377,6 @@ func TestUnreadCountUsesLastReadMessageID(t *testing.T) {
}
}
func TestSendAIMessageStoresWorkflowRunID(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
conversation := createMessageTestConversation(t, db, aiAgent.ID)
message, err := MessageService.SendAIMessageWithRequestIDAndWorkflowRunID(
conversation.ID,
aiAgent.ID,
"ai-reply-workflow-1",
enums.IMMessageTypeText,
"AI reply",
"",
workflowTestAIPrincipal(),
"trace-workflow-1",
9988,
)
if err != nil {
t.Fatalf("SendAIMessageWithRequestIDAndWorkflowRunID() error = %v", err)
}
if message.WorkflowRunID != 9988 {
t.Fatalf("message.WorkflowRunID=%d want 9988", message.WorkflowRunID)
}
var stored models.Message
if err := db.First(&stored, message.ID).Error; err != nil {
t.Fatalf("find message: %v", err)
}
if stored.WorkflowRunID != 9988 {
t.Fatalf("stored.WorkflowRunID=%d want 9988", stored.WorkflowRunID)
}
}
func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询")
@@ -436,6 +403,88 @@ func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation
}
}
func TestConversationCreateDoesNotReuseConversationFromAnotherChannel(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("channel-isolation-1")
first, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create first channel conversation: %v", err)
}
second, err := ConversationService.Create(external, 12, aiAgent.ID)
if err != nil {
t.Fatalf("create second channel conversation: %v", err)
}
if first.ID == second.ID {
t.Fatalf("conversation %d was incorrectly reused across channels", first.ID)
}
if first.ChannelID != 11 || second.ChannelID != 12 {
t.Fatalf("unexpected channel ids: first=%d second=%d", first.ChannelID, second.ChannelID)
}
}
func TestConversationCreateSynchronizesLatestAIAgentForUnassignedExistingConversation(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
external := welcomeTestExternalUser("sync-agent-1")
first, err := ConversationService.Create(external, 11, 0)
if err != nil {
t.Fatalf("create human conversation: %v", err)
}
aiAgent := createWelcomeTestAIAgent(t, db, "")
aiAgent.ServiceMode = enums.IMConversationServiceModeAIFirst
if err := db.Model(aiAgent).Update("service_mode", aiAgent.ServiceMode).Error; err != nil {
t.Fatalf("update ai agent service mode: %v", err)
}
second, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("reuse conversation with ai agent: %v", err)
}
if second.ID != first.ID {
t.Fatalf("conversation id = %d, want existing %d", second.ID, first.ID)
}
if second.ChannelID != 11 || second.AIAgentID != aiAgent.ID {
t.Fatalf("channel/agent = %d/%d, want 11/%d", second.ChannelID, second.AIAgentID, aiAgent.ID)
}
if second.ServiceMode != enums.IMConversationServiceModeAIFirst || second.Status != enums.IMConversationStatusAIServing {
t.Fatalf("mode/status = %d/%d, want ai-first/ai-serving", second.ServiceMode, second.Status)
}
}
func TestConversationCreateStartsNewAIConversationWhenAssignedConversationUsesStaleMode(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
external := welcomeTestExternalUser("keep-human-1")
conversation, err := ConversationService.Create(external, 11, 0)
if err != nil {
t.Fatalf("create human conversation: %v", err)
}
if err := db.Model(conversation).Updates(map[string]any{
"current_assignee_id": 9,
"status": enums.IMConversationStatusActive,
}).Error; err != nil {
t.Fatalf("assign conversation: %v", err)
}
aiAgent := createWelcomeTestAIAgent(t, db, "")
reused, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create ai conversation: %v", err)
}
if reused.ID == conversation.ID {
t.Fatalf("stale assigned conversation %d was reused", conversation.ID)
}
if reused.AIAgentID != aiAgent.ID || reused.ServiceMode != aiAgent.ServiceMode || reused.Status != enums.IMConversationStatusAIServing {
t.Fatalf("new conversation did not use latest ai config: %#v", reused)
}
preserved := ConversationService.Get(conversation.ID)
if preserved == nil || preserved.CurrentAssigneeID != 9 || preserved.Status != enums.IMConversationStatusActive {
t.Fatalf("old assigned conversation state changed: %#v", preserved)
}
}
func TestConversationCreateSkipsBlankWelcomeMessage(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, " ")
@@ -474,7 +523,7 @@ func TestConversationCreateWelcomeMessageDoesNotTriggerAIReplyHook(t *testing.T)
previousHook := TriggerAIReplyAsyncHook
called := false
TriggerAIReplyAsyncHook = func(conversation models.Conversation, message models.Message) {
TriggerAIReplyAsyncHook = func(_ context.Context, conversation models.Conversation, message models.Message) {
called = true
}
t.Cleanup(func() {