From 24802b23ce74e60dd9385e8bb666c722fb21be28 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 2 May 2026 11:32:39 +0800 Subject: [PATCH] feat(conversation): add schedule-aware handoff decisions --- .../services/conversation_dispatch_service.go | 18 +- .../conversation_human_dispatch_service.go | 234 +++++++++++++++ ...onversation_human_dispatch_service_test.go | 267 ++++++++++++++++++ internal/services/conversation_service.go | 38 +-- internal/services/message_service.go | 21 ++ 5 files changed, 541 insertions(+), 37 deletions(-) create mode 100644 internal/services/conversation_human_dispatch_service.go create mode 100644 internal/services/conversation_human_dispatch_service_test.go diff --git a/internal/services/conversation_dispatch_service.go b/internal/services/conversation_dispatch_service.go index c4383c5..7c71e89 100644 --- a/internal/services/conversation_dispatch_service.go +++ b/internal/services/conversation_dispatch_service.go @@ -361,14 +361,22 @@ func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, Lte("start_at", now). Gt("end_at", now)) - ret := make([]int64, 0, len(schedules)) - seen := make(map[int64]struct{}) + activeSet := make(map[int64]struct{}, len(schedules)) for _, schedule := range schedules { - if _, exists := seen[schedule.TeamID]; exists { + activeSet[schedule.TeamID] = struct{}{} + } + + ret := make([]int64, 0, len(teamIDs)) + seen := make(map[int64]struct{}) + for _, teamID := range teamIDs { + if _, active := activeSet[teamID]; !active { continue } - seen[schedule.TeamID] = struct{}{} - ret = append(ret, schedule.TeamID) + if _, exists := seen[teamID]; exists { + continue + } + seen[teamID] = struct{}{} + ret = append(ret, teamID) } return ret } diff --git a/internal/services/conversation_human_dispatch_service.go b/internal/services/conversation_human_dispatch_service.go new file mode 100644 index 0000000..dcb2323 --- /dev/null +++ b/internal/services/conversation_human_dispatch_service.go @@ -0,0 +1,234 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "cs-agent/internal/events" + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/errorsx" + "cs-agent/internal/pkg/eventbus" + "cs-agent/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +var ConversationHumanDispatchService = newConversationHumanDispatchService() + +const ( + HandoffWaitingMessage = "已为你转接人工客服,请稍候。" + HandoffOffHoursMessage = "当前暂不在人工客服服务时间内,你可以先继续描述问题,我会尽力协助;服务时间开始后也可以再次转人工。" +) + +type HandoffDecisionType string + +const ( + HandoffDecisionAssigned HandoffDecisionType = "assigned" + HandoffDecisionTeamPool HandoffDecisionType = "team_pool" + HandoffDecisionGlobalPool HandoffDecisionType = "global_pool" + HandoffDecisionOffHours HandoffDecisionType = "off_hours" +) + +type HandoffDecisionResult struct { + Decision HandoffDecisionType + TeamID int64 + AssigneeID int64 + Message string +} + +type conversationHumanDispatchService struct{} + +func newConversationHumanDispatchService() *conversationHumanDispatchService { + return &conversationHumanDispatchService{} +} + +func (s *conversationHumanDispatchService) HandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (*HandoffDecisionResult, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParam("会话不存在") + } + teamIDs := orderedPositiveIDs(aiAgent.TeamIDs) + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) + if len(activeTeamIDs) == 0 { + if err := s.createEvent(conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "转人工失败:非服务时间", strings.TrimSpace(reason)); err != nil { + return nil, err + } + if err := s.sendAIText(conversationID, aiAgent.ID, HandoffOffHoursMessage); err != nil { + return nil, err + } + return &HandoffDecisionResult{Decision: HandoffDecisionOffHours, Message: HandoffOffHoursMessage}, nil + } + + if err := s.markHandoff(conversationID, aiAgent, reason); err != nil { + return nil, err + } + return s.dispatchAfterHandoff(conversationID, aiAgent.ID, activeTeamIDs, strings.TrimSpace(reason), true) +} + +func (s *conversationHumanDispatchService) ApplyHumanOnlyCreate(conversationID int64, aiAgent models.AIAgent) (*HandoffDecisionResult, error) { + teamIDs := orderedPositiveIDs(aiAgent.TeamIDs) + activeTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(teamIDs, time.Now()) + if len(activeTeamIDs) == 0 { + if err := s.moveToGlobalPool(conversationID, aiAgent.Name); err != nil { + return nil, err + } + if err := s.sendAIText(conversationID, aiAgent.ID, HandoffWaitingMessage); err != nil { + return nil, err + } + return &HandoffDecisionResult{Decision: HandoffDecisionGlobalPool, Message: HandoffWaitingMessage}, nil + } + return s.dispatchAfterHandoff(conversationID, aiAgent.ID, activeTeamIDs, "仅人工模式新会话", false) +} + +func (s *conversationHumanDispatchService) dispatchAfterHandoff(conversationID, aiAgentID int64, activeTeamIDs []int64, reason string, publishAssignEvent bool) (*HandoffDecisionResult, error) { + if err := s.sendAIText(conversationID, aiAgentID, HandoffWaitingMessage); err != nil { + return nil, err + } + + candidates, _, err := ConversationDispatchService.pickDispatchCandidates(activeTeamIDs, time.Now()) + if err != nil { + return nil, err + } + if len(candidates) > 0 { + dispatched, err := ConversationDispatchService.tryAssignConversation(conversationID, candidates[0].profile, "自动分配") + if err != nil { + return nil, err + } + if dispatched != nil { + if publishAssignEvent { + eventbus.PublishAsync(context.Background(), events.ConversationAssignedEvent{ + ConversationID: dispatched.ID, + ToUserID: dispatched.CurrentAssigneeID, + OperatorID: systemDispatchPrincipal().UserID, + Reason: "自动分配", + AssignType: events.ConversationAssignTypeAutoAssign, + }) + } + return &HandoffDecisionResult{ + Decision: HandoffDecisionAssigned, + TeamID: dispatched.CurrentTeamID, + AssigneeID: dispatched.CurrentAssigneeID, + Message: HandoffWaitingMessage, + }, nil + } + } + + teamID := activeTeamIDs[0] + if err := s.moveToTeamPool(conversationID, teamID, reason); err != nil { + return nil, err + } + return &HandoffDecisionResult{Decision: HandoffDecisionTeamPool, TeamID: teamID, Message: HandoffWaitingMessage}, nil +} + +func (s *conversationHumanDispatchService) markHandoff(conversationID int64, aiAgent models.AIAgent, reason string) error { + now := time.Now() + trimmedReason := strings.TrimSpace(reason) + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "handoff_at": now, + "handoff_reason": trimmedReason, + "status": enums.IMConversationStatusPending, + "current_team_id": 0, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": aiAgent.Name, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", trimmedReason) + }) +} + +func (s *conversationHumanDispatchService) moveToTeamPool(conversationID, teamID int64, reason string) error { + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParam("会话不存在") + } + if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil { + return err + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "status": enums.IMConversationStatusPending, + "current_team_id": teamID, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": "system", + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeSystem, 0, "会话进入客服组待接入", ConversationService.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusPending, + "fromAssigneeId": conversation.CurrentAssigneeID, + "toAssigneeId": int64(0), + "toTeamId": teamID, + "reason": strings.TrimSpace(reason), + "decision": string(HandoffDecisionTeamPool), + })) + }) +} + +func (s *conversationHumanDispatchService) moveToGlobalPool(conversationID int64, operatorName string) error { + now := time.Now() + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) + if conversation == nil { + return errorsx.InvalidParam("会话不存在") + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ + "status": enums.IMConversationStatusPending, + "current_team_id": 0, + "current_assignee_id": 0, + "update_user_id": 0, + "update_user_name": operatorName, + "updated_at": now, + }); err != nil { + return err + } + return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeSystem, 0, "会话进入全局待接入", ConversationService.buildEventPayload(map[string]any{ + "fromStatus": conversation.Status, + "toStatus": enums.IMConversationStatusPending, + "decision": string(HandoffDecisionGlobalPool), + })) + }) +} + +func (s *conversationHumanDispatchService) createEvent(conversationID int64, eventType enums.IMEventType, senderType enums.IMSenderType, senderID int64, content, payload string) error { + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + return ConversationEventLogService.CreateEvent(ctx, conversationID, eventType, senderType, senderID, content, payload) + }) +} + +func (s *conversationHumanDispatchService) sendAIText(conversationID, aiAgentID int64, content string) error { + _, err := MessageService.SendAIServiceNotice(conversationID, aiAgentID, content) + return err +} + +func orderedPositiveIDs(value string) []int64 { + return uniquePositiveInt64sFromStrings(strings.Split(value, ",")) +} + +func uniquePositiveInt64sFromStrings(values []string) []int64 { + seen := make(map[int64]struct{}, len(values)) + ret := make([]int64, 0, len(values)) + for _, value := range values { + var id int64 + _, _ = fmt.Sscan(strings.TrimSpace(value), &id) + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ret = append(ret, id) + } + return ret +} diff --git a/internal/services/conversation_human_dispatch_service_test.go b/internal/services/conversation_human_dispatch_service_test.go new file mode 100644 index 0000000..962675f --- /dev/null +++ b/internal/services/conversation_human_dispatch_service_test.go @@ -0,0 +1,267 @@ +package services_test + +import ( + "strings" + "testing" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/openidentity" + "cs-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 || !strings.Contains(message.Content, "当前暂不在人工客服服务时间内") { + 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 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.User{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &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() + if err := db.Create(&models.User{ + ID: userID, + Username: "agent", + Nickname: "客服", + Status: enums.StatusOk, + }).Error; err != nil { + t.Fatalf("create user error = %v", err) + } + 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 +} diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index e554058..36fc852 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -163,14 +163,9 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha // 推送会话创建事件 WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationCreated) - // AI Agent仅人工模式,且有值班客服,尝试自动分配会话 - if conversation.Status == enums.IMConversationStatusPending && - aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly && - len(utils.SplitInt64s(aiAgent.TeamIDs)) > 0 { - if dispatched, err := ConversationDispatchService.DispatchPendingConversation(conversation, aiAgent); err != nil { + if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly { + if _, err := ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent); err != nil { return nil, err - } else if dispatched != nil { - return dispatched, nil } } return s.Get(conversation.ID), nil @@ -340,35 +335,14 @@ func (s *conversationService) HandoffByAI(conversationID int64, aiAgent models.A if conversationID <= 0 { return errorsx.InvalidParam("会话不存在") } - now := time.Now() - if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { - conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID) - if conversation == nil { - return errorsx.InvalidParam("会话不存在") - } - if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{ - "handoff_at": now, - "handoff_reason": strings.TrimSpace(reason), - "status": enums.IMConversationStatusPending, - "current_team_id": 0, - "current_assignee_id": 0, - "update_user_id": 0, - "update_user_name": aiAgent.Name, - "updated_at": now, - }); err != nil { - return err - } - return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason)) - }); err != nil { - return err - } - if _, err := ConversationDispatchService.DispatchConversation(conversationID); err != nil { - slog.Warn("auto dispatch conversation after ai handoff failed", + _, err := ConversationHumanDispatchService.HandoffByAI(conversationID, aiAgent, reason) + if err != nil { + slog.Warn("schedule-aware ai handoff failed", "conversation_id", conversationID, "ai_agent_id", aiAgent.ID, "error", err) } - return nil + return err } func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error { diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 70d500d..2912384 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -243,6 +243,21 @@ func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, cl return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil) } +func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int64, content string) (*models.Message, error) { + conversation := ConversationService.Get(conversationID) + if conversation == nil { + return nil, errorsx.InvalidParam("会话不存在") + } + if conversation.Status == enums.IMConversationStatusClosed { + return nil, errorsx.InvalidParam("会话已关闭") + } + return s.sendValidatedMessage(conversation, enums.IMSenderTypeAI, aiAgentID, "", enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{ + UserID: 0, + Username: "system", + Nickname: "system", + }, nil) +} + func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser) (*models.Message, error) { ext := external return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext) @@ -266,7 +281,13 @@ func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSe if err != nil { return nil, err } + return s.sendValidatedMessage(conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external) +} +func (s *messageService) sendValidatedMessage(conversation *models.Conversation, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, + messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Message, error) { + conversationID := conversation.ID + var err error var summary string content, payload, summary, err = s.normalizeMessageContent(conversationID, messageType, content, payload) if err != nil {