package services_test import ( "strings" "sync" "testing" "time" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/pkg/dto" "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" "code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity" "code.tczkiot.com/wlw/ai-agent/internal/services" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/schema" ) func TestConversationHumanDispatchAIHandoffOffHoursKeepsAIServingAndSendsNotice(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") if err != nil { t.Fatalf("HandoffByAI() error = %v", err) } if result == nil || result.Decision != services.HandoffDecisionOffHours { t.Fatalf("expected off_hours decision, got %+v", result) } current := services.ConversationService.Get(conversation.ID) if current.Status != enums.IMConversationStatusAIServing { t.Fatalf("expected conversation to stay AI serving, got status=%d", current.Status) } if current.HandoffAt != nil { t.Fatalf("expected handoffAt to stay nil, got %v", current.HandoffAt) } message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) if message == nil { t.Fatalf("expected off-hours notice message") } if message.SenderType != enums.IMSenderTypeAI || message.Content != services.HandoffOffHoursMessage { t.Fatalf("unexpected off-hours message: %+v", message) } } func TestConversationHumanDispatchAIHandoffAssignsAvailableAgent(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") createHumanDispatchTeam(t, db, 1, "售后支持组") createHumanDispatchActiveSchedule(t, db, 1) createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") if err != nil { t.Fatalf("HandoffByAI() error = %v", err) } if result == nil || result.Decision != services.HandoffDecisionAssigned { t.Fatalf("expected assigned decision, got %+v", result) } current := services.ConversationService.Get(conversation.ID) if current.Status != enums.IMConversationStatusActive { t.Fatalf("expected active conversation, got status=%d", current.Status) } if current.CurrentAssigneeID != 101 || current.CurrentTeamID != 1 { t.Fatalf("unexpected assignment: assignee=%d team=%d", current.CurrentAssigneeID, current.CurrentTeamID) } if current.HandoffAt == nil || current.HandoffReason != "用户要求转人工" { t.Fatalf("expected handoff metadata, got at=%v reason=%q", current.HandoffAt, current.HandoffReason) } } func TestConversationHumanDispatchAIHandoffFallsBackToFirstScheduledTeam(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "3,1,2") createHumanDispatchTeam(t, db, 1, "售后支持组") createHumanDispatchTeam(t, db, 2, "VIP支持组") createHumanDispatchTeam(t, db, 3, "非值班组") createHumanDispatchActiveSchedule(t, db, 1) createHumanDispatchActiveSchedule(t, db, 2) conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") if err != nil { t.Fatalf("HandoffByAI() error = %v", err) } if result == nil || result.Decision != services.HandoffDecisionTeamPool { t.Fatalf("expected team_pool decision, got %+v", result) } current := services.ConversationService.Get(conversation.ID) if current.Status != enums.IMConversationStatusPending { t.Fatalf("expected pending conversation, got status=%d", current.Status) } if current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 { t.Fatalf("expected fallback team 1 with no assignee, got team=%d assignee=%d", current.CurrentTeamID, current.CurrentAssigneeID) } } func TestConversationHumanDispatchHumanOnlyCreateOffHoursUsesGlobalPendingPool(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1") conversation, err := services.ConversationService.Create(openidentity.ExternalUser{ ExternalSource: enums.ExternalSourceGuest, ExternalID: "guest-human-only-off-hours", ExternalName: "非服务时间访客", }, 1, aiAgent.ID) if err != nil { t.Fatalf("Create() error = %v", err) } if conversation.Status != enums.IMConversationStatusPending { t.Fatalf("expected pending conversation, got status=%d", conversation.Status) } if conversation.CurrentTeamID != 0 || conversation.CurrentAssigneeID != 0 { t.Fatalf("expected global pending pool, got team=%d assignee=%d", conversation.CurrentTeamID, conversation.CurrentAssigneeID) } message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) if message == nil || message.Content != services.HandoffWaitingMessage { t.Fatalf("expected waiting message, got %+v", message) } } func TestConversationHumanDispatchHumanOnlyCreateAssignsAvailableAgent(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeHumanOnly, "1") createHumanDispatchTeam(t, db, 1, "售后支持组") createHumanDispatchActiveSchedule(t, db, 1) createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) conversation, err := services.ConversationService.Create(openidentity.ExternalUser{ ExternalSource: enums.ExternalSourceGuest, ExternalID: "guest-human-only-assigned", ExternalName: "服务时间访客", }, 1, aiAgent.ID) if err != nil { t.Fatalf("Create() error = %v", err) } if conversation.Status != enums.IMConversationStatusActive { t.Fatalf("expected active conversation, got status=%d", conversation.Status) } if conversation.CurrentAssigneeID != 101 || conversation.CurrentTeamID != 1 { t.Fatalf("unexpected assignment: assignee=%d team=%d", conversation.CurrentAssigneeID, conversation.CurrentTeamID) } } func TestConversationAutoAssignManualDispatchOffHoursReturnsBusinessMessage(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusPending) err := services.ConversationService.AutoAssignConversation(conversation.ID, testHumanDispatchOperator()) if err == nil { t.Fatalf("expected off-hours manual dispatch to fail") } if !strings.Contains(err.Error(), "当前暂不在人工客服服务时间内") { t.Fatalf("expected off-hours error, got %v", err) } } func TestConversationAutoAssignManualDispatchFallsBackToTeamPool(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") createHumanDispatchTeam(t, db, 1, "售后支持组") createHumanDispatchActiveSchedule(t, db, 1) conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusPending) err := services.ConversationService.AutoAssignConversation(conversation.ID, testHumanDispatchOperator()) if err != nil { t.Fatalf("AutoAssignConversation() error = %v", err) } current := services.ConversationService.Get(conversation.ID) if current.Status != enums.IMConversationStatusPending || current.CurrentTeamID != 1 || current.CurrentAssigneeID != 0 { t.Fatalf("expected team-pool pending conversation, got %+v", current) } } func TestConversationQueueOrdersByEffectivePriorityThenFIFO(t *testing.T) { setupConversationHumanDispatchTestDB(t) now := time.Now().Truncate(time.Second) oldest := now.Add(-6 * time.Minute) middle := now.Add(-2 * time.Minute) newest := now.Add(-time.Minute) queue := []models.Conversation{ {ID: 1, Status: enums.IMConversationStatusPending, Priority: 0, QueueEnteredAt: &middle}, {ID: 2, Status: enums.IMConversationStatusPending, Priority: 1, QueueEnteredAt: &newest}, {ID: 3, Status: enums.IMConversationStatusPending, Priority: 0, QueueEnteredAt: &oldest}, } services.ConversationQueueService.Sort(queue, now) if queue[0].ID != 3 || queue[1].ID != 2 || queue[2].ID != 1 { t.Fatalf("unexpected queue order: %d, %d, %d", queue[0].ID, queue[1].ID, queue[2].ID) } if level := services.ConversationQueueService.EscalationLevel(&queue[0], now); level != 1 { t.Fatalf("expected timeout escalation level 1, got %d", level) } } func TestConversationQueueSnapshotSeparatesTeamPools(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) now := time.Now().Truncate(time.Second) firstEnteredAt := now.Add(-3 * time.Minute) secondEnteredAt := now.Add(-2 * time.Minute) otherPoolEnteredAt := now.Add(-10 * time.Minute) first := models.Conversation{Status: enums.IMConversationStatusPending, CurrentTeamID: 1, QueueEnteredAt: &firstEnteredAt} second := models.Conversation{Status: enums.IMConversationStatusPending, CurrentTeamID: 1, QueueEnteredAt: &secondEnteredAt} otherPool := models.Conversation{Status: enums.IMConversationStatusPending, CurrentTeamID: 2, QueueEnteredAt: &otherPoolEnteredAt} for _, item := range []*models.Conversation{&first, &second, &otherPool} { if err := db.Create(item).Error; err != nil { t.Fatalf("create queued conversation: %v", err) } } firstSnapshot := services.ConversationQueueService.GetSnapshotAt(&first, now) secondSnapshot := services.ConversationQueueService.GetSnapshotAt(&second, now) otherSnapshot := services.ConversationQueueService.GetSnapshotAt(&otherPool, now) if firstSnapshot.Position != 1 || firstSnapshot.WaitingCount != 2 { t.Fatalf("unexpected first snapshot: %+v", firstSnapshot) } if secondSnapshot.Position != 2 || secondSnapshot.AheadCount != 1 { t.Fatalf("unexpected second snapshot: %+v", secondSnapshot) } if otherSnapshot.Position != 1 || otherSnapshot.WaitingCount != 1 { t.Fatalf("unexpected other-pool snapshot: %+v", otherSnapshot) } } func TestConversationPureHumanGlobalQueueDispatchesFIFOAndHonorsCapacity(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) createHumanDispatchTeam(t, db, 1, "售后支持组") createHumanDispatchActiveSchedule(t, db, 1) createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 1, true, enums.StatusOk) now := time.Now() olderEnteredAt := now.Add(-2 * time.Minute) newerEnteredAt := now.Add(-time.Minute) older := createHumanDispatchConversation(t, db, 0, enums.IMConversationStatusPending) newer := createHumanDispatchConversation(t, db, 0, enums.IMConversationStatusPending) if err := db.Model(&models.Conversation{}).Where("id = ?", older.ID).Update("queue_entered_at", olderEnteredAt).Error; err != nil { t.Fatalf("set older queue time: %v", err) } if err := db.Model(&models.Conversation{}).Where("id = ?", newer.ID).Update("queue_entered_at", newerEnteredAt).Error; err != nil { t.Fatalf("set newer queue time: %v", err) } count, err := services.ConversationDispatchService.DispatchPendingConversations(10) if err != nil { t.Fatalf("DispatchPendingConversations() error = %v", err) } if count != 1 { t.Fatalf("expected one dispatch at capacity, got %d", count) } olderCurrent := services.ConversationService.Get(older.ID) newerCurrent := services.ConversationService.Get(newer.ID) if olderCurrent.Status != enums.IMConversationStatusActive || olderCurrent.CurrentAssigneeID != 101 { t.Fatalf("expected oldest conversation assigned first, got %+v", olderCurrent) } if newerCurrent.Status != enums.IMConversationStatusPending || newerCurrent.CurrentAssigneeID != 0 { t.Fatalf("expected newer conversation to remain queued, got %+v", newerCurrent) } } func TestConversationConcurrentAutoDispatchCreatesSingleAssignment(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) createHumanDispatchTeam(t, db, 1, "售后支持组") createHumanDispatchActiveSchedule(t, db, 1) createHumanDispatchAgentProfile(t, db, 101, 1, enums.ServiceStatusIdle, 3, true, enums.StatusOk) conversation := createHumanDispatchConversation(t, db, 0, enums.IMConversationStatusPending) now := time.Now() if err := db.Model(&models.Conversation{}).Where("id = ?", conversation.ID).Update("queue_entered_at", now).Error; err != nil { t.Fatalf("set queue time: %v", err) } var waitGroup sync.WaitGroup for range 8 { waitGroup.Add(1) go func() { defer waitGroup.Done() _, _ = services.ConversationDispatchService.DispatchConversation(conversation.ID) }() } waitGroup.Wait() var assignmentCount int64 if err := db.Model(&models.ConversationAssignment{}).Where("conversation_id = ?", conversation.ID).Count(&assignmentCount).Error; err != nil { t.Fatalf("count assignments: %v", err) } if assignmentCount != 1 { t.Fatalf("expected exactly one assignment, got %d", assignmentCount) } } func setupConversationHumanDispatchTestDB(t *testing.T) *gorm.DB { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: "t_", SingularTable: true, }, }) if err != nil { t.Fatalf("open sqlite error = %v", err) } t.Cleanup(func() { sqlDB, err := db.DB() if err == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate( &models.AIAgent{}, &models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.AgentProfile{}, &models.Conversation{}, &models.ConversationParticipant{}, &models.ConversationAssignment{}, &models.ConversationEventLog{}, &models.ConversationReadState{}, &models.Message{}, &models.ChannelMessageOutbox{}, ); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) return db } func createHumanDispatchAIAgent(t *testing.T, db *gorm.DB, mode enums.IMConversationServiceMode, teamIDs string) models.AIAgent { t.Helper() item := models.AIAgent{ Name: "测试AI", ServiceMode: mode, TeamIDs: teamIDs, Status: enums.StatusOk, } if err := db.Create(&item).Error; err != nil { t.Fatalf("create ai agent error = %v", err) } return item } func createHumanDispatchTeam(t *testing.T, db *gorm.DB, id int64, name string) { t.Helper() if err := db.Create(&models.AgentTeam{ID: id, Name: name, Status: enums.StatusOk}).Error; err != nil { t.Fatalf("create team error = %v", err) } } func createHumanDispatchActiveSchedule(t *testing.T, db *gorm.DB, teamID int64) { t.Helper() now := time.Now() if err := db.Create(&models.AgentTeamSchedule{ TeamID: teamID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk, }).Error; err != nil { t.Fatalf("create schedule error = %v", err) } } func createHumanDispatchAgentProfile(t *testing.T, db *gorm.DB, userID, teamID int64, serviceStatus enums.ServiceStatus, maxConcurrent int, autoAssign bool, status enums.Status) { t.Helper() registerTestExternalSubject(userID, "agent", "客服", enums.StatusOk) if err := db.Create(&models.AgentProfile{ UserID: userID, TeamID: teamID, AgentCode: "A001", DisplayName: "客服", ServiceStatus: serviceStatus, MaxConcurrentCount: maxConcurrent, AutoAssignEnabled: autoAssign, Status: status, }).Error; err != nil { t.Fatalf("create profile error = %v", err) } } func createHumanDispatchConversation(t *testing.T, db *gorm.DB, aiAgentID int64, status enums.IMConversationStatus) models.Conversation { t.Helper() now := time.Now() item := models.Conversation{ AIAgentID: aiAgentID, ChannelID: 1, CustomerID: 1, CustomerName: "测试访客", Status: status, ServiceMode: enums.IMConversationServiceModeAIFirst, LastMessageAt: now, LastActiveAt: now, } if err := db.Create(&item).Error; err != nil { t.Fatalf("create conversation error = %v", err) } return item } func testHumanDispatchOperator() *dto.AuthPrincipal { return &dto.AuthPrincipal{UserID: 9, Username: "dispatcher", Nickname: "调度员"} }