refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.tczkiot.com/wlw/ai-agent/contract"
|
||||
"code.tczkiot.com/wlw/ai-agent/identity"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
func TestCustomerQuickActionsResolveKnownCardWithoutChangingGuestOwnership(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("card:50506783")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
originalCustomerType := conversation.CustomerType
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if len(query.Types) == 1 && query.Types[0] == identity.SubjectCard && query.Keyword == "50506783" {
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectCard, Category: identity.CategoryUser, ID: 17443,
|
||||
Username: "50506783", Identifier: "898608691025D4186783", Name: "卡号 50506783", Enabled: true,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
t.Cleanup(func() { SetQuerySubjects(nil) })
|
||||
|
||||
var executedContext contract.BusinessReadContext
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{
|
||||
{
|
||||
Code: "card/traffic", Title: "查流量", Message: "请查询流量", CustomerTypes: []string{"card"},
|
||||
Execute: func(_ context.Context, businessContext contract.BusinessReadContext) (string, error) {
|
||||
executedContext = businessContext
|
||||
return "剩余流量 30G", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "device/status", Title: "查设备", Message: "请查询设备", CustomerTypes: []string{"device"},
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) { return "设备正常", nil },
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 1 || actions[0].Code != "card/traffic" {
|
||||
t.Fatalf("known card actions = %#v, err = %v", actions, err)
|
||||
}
|
||||
if conversation.CustomerType != originalCustomerType || !ConversationService.IsCustomerConversationOwner(conversation, external) {
|
||||
t.Fatalf("quick-action resolution changed guest ownership: %#v", conversation)
|
||||
}
|
||||
if _, _, err := CustomerQuickActionService.ExecuteAndRecord(
|
||||
context.Background(), conversation.ID, "card/traffic", "known-card-1", external, "known-card-request-1",
|
||||
); err != nil {
|
||||
t.Fatalf("ExecuteAndRecord() error = %v", err)
|
||||
}
|
||||
if executedContext.CustomerType != "card" || executedContext.CustomerID != 17443 || executedContext.CustomerExternalID != "50506783" {
|
||||
t.Fatalf("unexpected business context: %#v", executedContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionsResolveDeviceFromConversationHistory(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("quick-device-history")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
SetQuerySubjects(func(_ context.Context, query identity.Query) ([]identity.Subject, error) {
|
||||
if len(query.Types) == 1 && query.Types[0] == identity.SubjectDevice && query.Keyword == "37012627000987" {
|
||||
return []identity.Subject{{
|
||||
Type: identity.SubjectDevice, Category: identity.CategoryUser, ID: 27,
|
||||
Username: "37012627000987", Identifier: "37012627000987", Name: "设备号 37012627000987", Enabled: true,
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
t.Cleanup(func() { SetQuerySubjects(nil) })
|
||||
if _, err := MessageService.SendCustomerMessageWithoutAIReplyWithRequestID(
|
||||
conversation.ID, "known-device-message", enums.IMMessageTypeHTML,
|
||||
"<p>设备号 37012627000987</p>", "", external, "known-device-request",
|
||||
); err != nil {
|
||||
t.Fatalf("send identity message: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "device/wifi", Title: "WiFi 信息", Message: "查询 WiFi", CustomerTypes: []string{"device"},
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) { return "WiFi 正常", nil },
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 1 || actions[0].Code != "device/wifi" {
|
||||
t.Fatalf("known device actions = %#v, err = %v", actions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionRecordsReplyWithoutTriggeringAI(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("quick-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/status",
|
||||
Title: "查状态",
|
||||
Message: "请查询状态",
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "当前状态正常", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
previousHook := TriggerAIReplyAsyncHook
|
||||
called := false
|
||||
TriggerAIReplyAsyncHook = func(context.Context, models.Conversation, models.Message) { called = true }
|
||||
t.Cleanup(func() { TriggerAIReplyAsyncHook = previousHook })
|
||||
|
||||
customerMessage, replyMessage, err := CustomerQuickActionService.ExecuteAndRecord(
|
||||
context.Background(), conversation.ID, "test/status", "quick-client-1", external, "quick-request-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteAndRecord() error = %v", err)
|
||||
}
|
||||
if customerMessage.Content != "请查询状态" || customerMessage.SenderType != enums.IMSenderTypeCustomer {
|
||||
t.Fatalf("unexpected customer message: %#v", customerMessage)
|
||||
}
|
||||
if replyMessage.Content != "当前状态正常" || replyMessage.SenderType != enums.IMSenderTypeAI {
|
||||
t.Fatalf("unexpected automatic reply: %#v", replyMessage)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("quick action customer message must not trigger an AI reply")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("message count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionAvailabilityIsCheckedForListAndExecution(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("quick-action-availability-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
available := false
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/dynamic", Title: "动态操作", Message: "执行动态操作",
|
||||
Available: func(context.Context, contract.BusinessReadContext) (bool, error) {
|
||||
return available, nil
|
||||
},
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "执行成功", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
actions, err := CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 0 {
|
||||
t.Fatalf("unavailable action leaked into list: actions=%#v err=%v", actions, err)
|
||||
}
|
||||
if _, _, err := CustomerQuickActionService.ExecuteAndRecord(context.Background(), conversation.ID, "test/dynamic", "quick-dynamic-1", external, "quick-dynamic-request-1"); err == nil {
|
||||
t.Fatal("unavailable action was executed")
|
||||
}
|
||||
|
||||
available = true
|
||||
actions, err = CustomerQuickActionService.ListForConversation(context.Background(), conversation)
|
||||
if err != nil || len(actions) != 1 || actions[0].Code != "test/dynamic" {
|
||||
t.Fatalf("available action missing from list: actions=%#v err=%v", actions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionExecutesMatchedRecordedMessage(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("matched-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/traffic", Title: "查流量", Message: "请查询流量",
|
||||
MatchIntent: func(message string) bool { return message == "卡号 50506783,请查询流量" },
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "剩余流量:58.38G", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
message, err := MessageService.SendCustomerMessageWithoutAIReplyWithRequestID(
|
||||
conversation.ID, "matched-customer-1", enums.IMMessageTypeText,
|
||||
"卡号 50506783,请查询流量", "", external, "matched-request-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("send customer message: %v", err)
|
||||
}
|
||||
matched, err := CustomerQuickActionService.ExecuteMatchedReply(
|
||||
context.Background(), conversation, message.Content, message.RequestID, message.ID,
|
||||
)
|
||||
if err != nil || !matched {
|
||||
t.Fatalf("ExecuteMatchedReply() matched=%v err=%v", matched, err)
|
||||
}
|
||||
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
|
||||
if len(list) != 2 || list[1].Content != "剩余流量:58.38G" || list[1].SenderType != enums.IMSenderTypeAI {
|
||||
t.Fatalf("unexpected messages: %#v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionExecutesSelectedMenuItem(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("selected-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{{
|
||||
Code: "test/traffic", Title: "查流量", Message: "请查询流量", Sort: 10,
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "剩余流量:58.38G", nil
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
matched, aiMessage, err := CustomerQuickActionService.ExecuteSelectedReply(
|
||||
context.Background(), conversation, 1, "selected-request-1", 100,
|
||||
)
|
||||
if err != nil || !matched || aiMessage != "" {
|
||||
t.Fatalf("ExecuteSelectedReply() matched=%v aiMessage=%q err=%v", matched, aiMessage, err)
|
||||
}
|
||||
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
|
||||
if len(list) != 1 || list[0].Content != "剩余流量:58.38G" {
|
||||
t.Fatalf("unexpected selected action messages: %#v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerQuickActionExecutesActionByCode(t *testing.T) {
|
||||
db := setupMessageWelcomeTestDB(t)
|
||||
aiAgent := createWelcomeTestAIAgent(t, db, "")
|
||||
external := welcomeTestExternalUser("coded-action-user")
|
||||
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
if err := SetCustomerQuickActions([]contract.CustomerQuickAction{
|
||||
{
|
||||
Code: "test/status", Title: "查状态", Message: "请查询状态", Sort: 10,
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "当前状态正常", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "test/diagnosis", Title: "智能检测", Message: "请智能检测", Sort: 20,
|
||||
Execute: func(context.Context, contract.BusinessReadContext) (string, error) {
|
||||
return "智能检测结果:网络异常", nil
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCustomerQuickActions() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = SetCustomerQuickActions(nil) })
|
||||
|
||||
matched, aiMessage, err := CustomerQuickActionService.ExecuteActionReply(
|
||||
context.Background(), conversation, "test/diagnosis", "coded-request-1", 101,
|
||||
)
|
||||
if err != nil || !matched || aiMessage != "" {
|
||||
t.Fatalf("ExecuteActionReply() matched=%v aiMessage=%q err=%v", matched, aiMessage, err)
|
||||
}
|
||||
list, _, _ := MessageService.FindByConversationIDCursor(conversation.ID, 0, 20, "", "")
|
||||
if len(list) != 1 || list[0].Content != "智能检测结果:网络异常" {
|
||||
t.Fatalf("unexpected coded action messages: %#v", list)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user