2026-04-09 10:01:23 +08:00
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-21 16:41:12 +08:00
|
|
|
"context"
|
2026-04-09 10:01:23 +08:00
|
|
|
"encoding/json"
|
2026-04-10 16:00:10 +08:00
|
|
|
"log/slog"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
2026-08-21 00:41:07 +08:00
|
|
|
"code.tczkiot.com/wlw/ai-agent/identity"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/events"
|
|
|
|
|
"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/dto/request"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/eventbus"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
|
|
|
|
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
2026-04-09 10:01:23 +08:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/mlogclub/simple/common/strs"
|
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
2026-04-27 19:04:10 +08:00
|
|
|
"gorm.io/gorm"
|
2026-04-09 10:01:23 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var ConversationService = newConversationService()
|
|
|
|
|
|
|
|
|
|
func newConversationService() *conversationService {
|
|
|
|
|
return &conversationService{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type conversationService struct {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) Get(id int64) *models.Conversation {
|
|
|
|
|
if id <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return repositories.ConversationRepository.Get(sqls.DB(), id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) Find(cnd *sqls.Cnd) []models.Conversation {
|
|
|
|
|
return repositories.ConversationRepository.Find(sqls.DB(), cnd)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) FindOne(cnd *sqls.Cnd) *models.Conversation {
|
|
|
|
|
return repositories.ConversationRepository.FindOne(sqls.DB(), cnd)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Conversation, paging *sqls.Paging) {
|
|
|
|
|
return repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) ListConversations(userID int64, filter request.AgentConversationFilter, keyword string, paging *sqls.Paging) ([]models.Conversation, *sqls.Paging, error) {
|
|
|
|
|
cnd := sqls.NewCnd().Page(paging.Page, paging.Limit)
|
|
|
|
|
|
|
|
|
|
if strs.IsNotBlank(keyword) {
|
|
|
|
|
keyword = strings.TrimSpace(keyword)
|
2026-04-27 19:22:40 +08:00
|
|
|
keywordLike := "%" + keyword + "%"
|
2026-04-27 19:27:24 +08:00
|
|
|
cnd.Where("customer_name LIKE ? OR last_message_summary LIKE ?", keywordLike, keywordLike)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch filter {
|
2026-04-11 13:55:11 +08:00
|
|
|
case request.AgentConversationFilterAIServing:
|
|
|
|
|
cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusAIServing).Desc("last_active_at").Desc("id")
|
2026-04-09 10:01:23 +08:00
|
|
|
case request.AgentConversationFilterMine:
|
|
|
|
|
cnd.Eq("current_assignee_id", userID).Desc("last_active_at").Desc("id")
|
|
|
|
|
case request.AgentConversationFilterActive:
|
|
|
|
|
cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusActive).Desc("last_active_at").Desc("id")
|
|
|
|
|
case request.AgentConversationFilterPending:
|
|
|
|
|
cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusPending).Asc("last_active_at").Desc("id")
|
|
|
|
|
case request.AgentConversationFilterClosed:
|
|
|
|
|
cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id")
|
|
|
|
|
default:
|
2026-06-02 20:51:13 +08:00
|
|
|
return nil, nil, errorsx.InvalidParamI18n("error.e0121")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
|
|
|
|
|
return list, paging, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) Updates(id int64, columns map[string]interface{}) error {
|
|
|
|
|
return repositories.ConversationRepository.Updates(sqls.DB(), id, columns)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
func (s *conversationService) getLatestNotFinishedByExternalUser(db *gorm.DB, externalUser openidentity.ExternalUser, channelID int64) *models.Conversation {
|
|
|
|
|
externalID := strings.TrimSpace(externalUser.ExternalID)
|
|
|
|
|
if externalID == "" || channelID <= 0 {
|
2026-04-27 19:04:10 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
cnd := sqls.NewCnd()
|
2026-08-28 22:23:13 +08:00
|
|
|
cnd.Eq("channel_id", channelID)
|
|
|
|
|
cnd.Eq("customer_type", externalCustomerType(externalUser))
|
|
|
|
|
cnd.Eq("customer_external_id", externalID)
|
2026-04-09 10:01:23 +08:00
|
|
|
cnd.In("status", []enums.IMConversationStatus{
|
2026-04-11 13:55:11 +08:00
|
|
|
enums.IMConversationStatusAIServing,
|
2026-04-09 10:01:23 +08:00
|
|
|
enums.IMConversationStatusPending,
|
|
|
|
|
enums.IMConversationStatusActive,
|
|
|
|
|
})
|
|
|
|
|
cnd.Desc("id")
|
2026-04-27 19:04:10 +08:00
|
|
|
return repositories.ConversationRepository.FindOne(db, cnd)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 11:10:47 +08:00
|
|
|
func (s *conversationService) Create(externalUser openidentity.ExternalUser, channelID, aiAgentID int64) (*models.Conversation, error) {
|
2026-08-20 21:46:55 +08:00
|
|
|
serviceMode := enums.IMConversationServiceModeHumanOnly
|
|
|
|
|
var aiAgent *models.AIAgent
|
|
|
|
|
if aiAgentID > 0 {
|
|
|
|
|
aiAgent = AIAgentService.Get(aiAgentID)
|
|
|
|
|
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
|
|
|
|
return nil, errorsx.InvalidParamI18n("error.e0002")
|
|
|
|
|
}
|
|
|
|
|
serviceMode = aiAgent.ServiceMode
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 19:04:10 +08:00
|
|
|
var conversation *models.Conversation
|
2026-05-02 12:30:27 +08:00
|
|
|
var welcomeMessage *models.Message
|
2026-04-27 19:04:10 +08:00
|
|
|
created := false
|
2026-08-28 22:23:13 +08:00
|
|
|
reconfigured := false
|
2026-04-09 10:01:23 +08:00
|
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
2026-08-28 22:23:13 +08:00
|
|
|
customerType := externalCustomerType(externalUser)
|
|
|
|
|
customerID := externalUser.SubjectID
|
|
|
|
|
customerName := strings.TrimSpace(externalUser.ExternalName)
|
|
|
|
|
existing := s.getLatestNotFinishedByExternalUser(ctx.Tx, externalUser, channelID)
|
|
|
|
|
// A conversation already being handled by a human keeps its original
|
|
|
|
|
// service contract. If the channel was switched to another Agent/mode,
|
|
|
|
|
// start a new conversation with the latest config instead of silently
|
|
|
|
|
// reusing the stale human conversation.
|
|
|
|
|
if existing != nil && (existing.CurrentAssigneeID > 0 || existing.HandoffAt != nil) &&
|
|
|
|
|
(existing.AIAgentID != aiAgentID || existing.ServiceMode != serviceMode) {
|
|
|
|
|
existing = nil
|
|
|
|
|
}
|
|
|
|
|
if existing != nil {
|
2026-04-27 19:04:10 +08:00
|
|
|
conversation = existing
|
2026-08-28 22:23:13 +08:00
|
|
|
updates := make(map[string]any)
|
2026-04-27 19:27:24 +08:00
|
|
|
if customerName != "" && existing.CustomerName != customerName {
|
2026-08-28 22:23:13 +08:00
|
|
|
updates["customer_name"] = customerName
|
|
|
|
|
conversation.CustomerName = customerName
|
|
|
|
|
}
|
|
|
|
|
// A channel binding may change after a conversation was created. Keep an
|
|
|
|
|
// unassigned conversation aligned with the latest channel/Agent config,
|
|
|
|
|
// while never taking a conversation away from a human or a handoff flow.
|
|
|
|
|
if existing.CurrentAssigneeID == 0 && existing.HandoffAt == nil &&
|
|
|
|
|
(existing.ChannelID != channelID || existing.AIAgentID != aiAgentID || existing.ServiceMode != serviceMode) {
|
|
|
|
|
updates["channel_id"] = channelID
|
|
|
|
|
updates["ai_agent_id"] = aiAgentID
|
|
|
|
|
updates["service_mode"] = serviceMode
|
|
|
|
|
updates["status"] = s.resolveInitialStatus(serviceMode)
|
|
|
|
|
conversation.ChannelID = channelID
|
|
|
|
|
conversation.AIAgentID = aiAgentID
|
|
|
|
|
conversation.ServiceMode = serviceMode
|
|
|
|
|
conversation.Status = s.resolveInitialStatus(serviceMode)
|
|
|
|
|
reconfigured = true
|
|
|
|
|
}
|
|
|
|
|
if len(updates) > 0 {
|
|
|
|
|
updates["updated_at"] = time.Now()
|
|
|
|
|
if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, updates); err != nil {
|
2026-04-27 19:27:24 +08:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-27 19:04:10 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
created = true
|
|
|
|
|
now := time.Now()
|
|
|
|
|
conversation = &models.Conversation{
|
2026-08-28 22:23:13 +08:00
|
|
|
AIAgentID: aiAgentID,
|
|
|
|
|
ChannelID: channelID,
|
|
|
|
|
CustomerType: customerType,
|
|
|
|
|
CustomerID: customerID,
|
|
|
|
|
CustomerExternalID: strings.TrimSpace(externalUser.ExternalID),
|
|
|
|
|
CustomerName: customerName,
|
|
|
|
|
Status: s.resolveInitialStatus(serviceMode),
|
|
|
|
|
ServiceMode: serviceMode,
|
|
|
|
|
Priority: 0,
|
|
|
|
|
CurrentAssigneeID: 0,
|
|
|
|
|
CurrentTeamID: 0,
|
|
|
|
|
LastMessageAt: now,
|
|
|
|
|
LastActiveAt: now,
|
|
|
|
|
AuditFields: utils.BuildAuditFields(nil),
|
2026-04-27 19:04:10 +08:00
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
if err := ctx.Tx.Create(conversation).Error; err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-28 11:10:47 +08:00
|
|
|
if err := ConversationParticipantService.CreateCustomerParticipant(ctx, conversation.ID, externalUser); err != nil {
|
2026-04-09 10:01:23 +08:00
|
|
|
return err
|
|
|
|
|
}
|
2026-05-02 12:30:27 +08:00
|
|
|
if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", ""); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-20 21:46:55 +08:00
|
|
|
if aiAgent != nil {
|
2026-08-28 22:23:13 +08:00
|
|
|
var welcomeErr error
|
|
|
|
|
welcomeMessage, welcomeErr = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
|
|
|
|
|
return welcomeErr
|
2026-08-20 21:46:55 +08:00
|
|
|
}
|
|
|
|
|
return nil
|
2026-04-09 10:01:23 +08:00
|
|
|
}); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-27 19:04:10 +08:00
|
|
|
if conversation == nil {
|
2026-06-02 21:10:17 +08:00
|
|
|
return nil, errorsx.BusinessErrorI18n(1, "error.conversation.createFailed")
|
2026-04-27 19:04:10 +08:00
|
|
|
}
|
|
|
|
|
if !created {
|
2026-08-28 22:23:13 +08:00
|
|
|
if reconfigured {
|
|
|
|
|
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated)
|
|
|
|
|
}
|
|
|
|
|
return s.Get(conversation.ID), nil
|
2026-04-27 19:04:10 +08:00
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
|
|
|
|
|
// 推送会话创建事件
|
|
|
|
|
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationCreated)
|
2026-05-02 12:30:27 +08:00
|
|
|
if welcomeMessage != nil {
|
|
|
|
|
if updatedConversation := s.Get(conversation.ID); updatedConversation != nil {
|
|
|
|
|
WsService.PublishMessageCreated(updatedConversation, welcomeMessage)
|
|
|
|
|
WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
|
2026-08-20 21:46:55 +08:00
|
|
|
if serviceMode == enums.IMConversationServiceModeHumanOnly {
|
|
|
|
|
var err error
|
|
|
|
|
if aiAgent == nil {
|
|
|
|
|
_, err = ConversationHumanDispatchService.ApplyHumanChannelCreate(conversation.ID)
|
|
|
|
|
} else {
|
|
|
|
|
_, err = ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return s.Get(conversation.ID), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) AssignConversation(req request.AssignConversationRequest, operator *dto.AuthPrincipal) error {
|
|
|
|
|
if operator == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.UnauthorizedI18n("error.auth.expired")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
targetProfile := AgentProfileService.GetByUserID(req.AssigneeID)
|
|
|
|
|
if targetProfile == nil || targetProfile.Status != enums.StatusOk {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0276")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-04-21 16:41:12 +08:00
|
|
|
var assignedEvent events.ConversationAssignedEvent
|
2026-08-28 22:23:13 +08:00
|
|
|
var previousTeamID int64
|
2026-04-09 10:01:23 +08:00
|
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
|
|
|
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.Status != enums.IMConversationStatusPending {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0135")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
previousTeamID = conversation.CurrentTeamID
|
2026-04-09 10:01:23 +08:00
|
|
|
now := time.Now()
|
|
|
|
|
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
|
|
|
|
|
"current_assignee_id": req.AssigneeID,
|
2026-08-28 22:23:13 +08:00
|
|
|
"current_team_id": targetProfile.TeamID,
|
2026-04-09 10:01:23 +08:00
|
|
|
"status": enums.IMConversationStatusActive,
|
|
|
|
|
"update_user_id": operator.UserID,
|
|
|
|
|
"update_user_name": operator.Username,
|
|
|
|
|
"updated_at": now,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-21 16:41:12 +08:00
|
|
|
if err := ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{
|
2026-08-28 22:23:13 +08:00
|
|
|
"from_status": conversation.Status,
|
|
|
|
|
"to_status": enums.IMConversationStatusActive,
|
|
|
|
|
"from_assignee_id": conversation.CurrentAssigneeID,
|
|
|
|
|
"to_assignee_id": req.AssigneeID,
|
|
|
|
|
"to_team_id": targetProfile.TeamID,
|
|
|
|
|
"reason": strings.TrimSpace(req.Reason),
|
2026-04-21 16:41:12 +08:00
|
|
|
})); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
assignedEvent = events.ConversationAssignedEvent{
|
|
|
|
|
ConversationID: req.ConversationID,
|
|
|
|
|
FromUserID: conversation.CurrentAssigneeID,
|
|
|
|
|
ToUserID: req.AssigneeID,
|
|
|
|
|
OperatorID: operator.UserID,
|
|
|
|
|
Reason: strings.TrimSpace(req.Reason),
|
|
|
|
|
AssignType: events.ConversationAssignTypeAssign,
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-04-09 10:01:23 +08:00
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if conversation := s.Get(req.ConversationID); conversation != nil {
|
|
|
|
|
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned)
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
ConversationQueueService.PublishPoolUpdates(previousTeamID)
|
2026-04-21 16:41:12 +08:00
|
|
|
eventbus.PublishAsync(context.Background(), assignedEvent)
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) AutoAssignConversation(conversationID int64, operator *dto.AuthPrincipal) error {
|
|
|
|
|
if operator == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.UnauthorizedI18n("error.auth.expired")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conversation := s.Get(conversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.Status != enums.IMConversationStatusPending {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0136")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.CurrentAssigneeID > 0 {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0190")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
if conversation.AIAgentID > 0 {
|
|
|
|
|
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
|
|
|
|
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
|
|
|
|
return errorsx.InvalidParamI18n("error.e0003")
|
|
|
|
|
}
|
|
|
|
|
result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if result == nil || result.Decision == HandoffDecisionOffHours {
|
|
|
|
|
return errorsx.InvalidParamI18n("error.e0194")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-05-02 11:33:58 +08:00
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
result, err := ConversationDispatchService.DispatchConversation(conversationID)
|
2026-04-09 10:01:23 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
if result == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0194")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) TransferConversation(conversationID, toUserID int64, reason string, operator *dto.AuthPrincipal) error {
|
|
|
|
|
if operator == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.UnauthorizedI18n("error.auth.expired")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if toUserID <= 0 {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0278")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
targetProfile := AgentProfileService.GetByUserID(toUserID)
|
|
|
|
|
if targetProfile == nil || targetProfile.Status != enums.StatusOk {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0276")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-04-21 16:41:12 +08:00
|
|
|
var assignedEvent events.ConversationAssignedEvent
|
2026-04-09 10:01:23 +08:00
|
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
|
|
|
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if !s.canTransferConversation(conversation, operator) {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.ForbiddenI18n("error.e0223")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.Status != enums.IMConversationStatusActive {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0134")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.CurrentAssigneeID <= 0 {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0193")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.CurrentAssigneeID == toUserID {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0277")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
now := time.Now()
|
|
|
|
|
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, toUserID, enums.IMAssignmentTypeTransfer, reason, operator, now); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
|
|
|
|
"current_assignee_id": toUserID,
|
|
|
|
|
"status": enums.IMConversationStatusActive,
|
|
|
|
|
"update_user_id": operator.UserID,
|
|
|
|
|
"update_user_name": operator.Username,
|
|
|
|
|
"updated_at": now,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-21 16:41:12 +08:00
|
|
|
if err := ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "会话已转接", s.buildEventPayload(map[string]any{
|
2026-08-28 22:23:13 +08:00
|
|
|
"from_status": conversation.Status,
|
|
|
|
|
"to_status": enums.IMConversationStatusActive,
|
|
|
|
|
"from_assignee_id": conversation.CurrentAssigneeID,
|
|
|
|
|
"to_assignee_id": toUserID,
|
|
|
|
|
"reason": strings.TrimSpace(reason),
|
2026-04-21 16:41:12 +08:00
|
|
|
})); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
assignedEvent = events.ConversationAssignedEvent{
|
|
|
|
|
ConversationID: conversationID,
|
|
|
|
|
FromUserID: conversation.CurrentAssigneeID,
|
|
|
|
|
ToUserID: toUserID,
|
|
|
|
|
OperatorID: operator.UserID,
|
|
|
|
|
Reason: strings.TrimSpace(reason),
|
|
|
|
|
AssignType: events.ConversationAssignTypeTransfer,
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-04-09 10:01:23 +08:00
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if conversation := s.Get(conversationID); conversation != nil {
|
|
|
|
|
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationTransferred)
|
|
|
|
|
}
|
2026-04-21 16:41:12 +08:00
|
|
|
eventbus.PublishAsync(context.Background(), assignedEvent)
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 17:57:01 +08:00
|
|
|
func (s *conversationService) HandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) error {
|
2026-05-27 22:18:43 +08:00
|
|
|
return s.HandoffByAIWithRequestID(conversationID, aiAgent, reason, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) HandoffByAIWithRequestID(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) error {
|
2026-04-10 16:00:10 +08:00
|
|
|
if conversationID <= 0 {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-10 16:00:10 +08:00
|
|
|
}
|
2026-05-27 22:18:43 +08:00
|
|
|
_, err := ConversationHumanDispatchService.HandoffByAIWithRequestID(conversationID, aiAgent, reason, requestID)
|
2026-05-02 11:32:39 +08:00
|
|
|
if err != nil {
|
|
|
|
|
slog.Warn("schedule-aware ai handoff failed",
|
2026-05-27 22:18:43 +08:00
|
|
|
"requestId", requestID,
|
2026-04-10 16:00:10 +08:00
|
|
|
"conversation_id", conversationID,
|
|
|
|
|
"ai_agent_id", aiAgent.ID,
|
|
|
|
|
"error", err)
|
|
|
|
|
}
|
2026-05-02 11:32:39 +08:00
|
|
|
return err
|
2026-04-10 16:00:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:26:25 +08:00
|
|
|
func (s *conversationService) TryOffHoursHandoffByAI(conversationID int64, aiAgent models.AIAgent, reason string) (bool, error) {
|
2026-05-27 22:18:43 +08:00
|
|
|
return s.TryOffHoursHandoffByAIWithRequestID(conversationID, aiAgent, reason, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) TryOffHoursHandoffByAIWithRequestID(conversationID int64, aiAgent models.AIAgent, reason string, requestID string) (bool, error) {
|
2026-05-08 23:26:25 +08:00
|
|
|
if conversationID <= 0 {
|
2026-06-02 20:51:13 +08:00
|
|
|
return false, errorsx.InvalidParamI18n("error.e0116")
|
2026-05-08 23:26:25 +08:00
|
|
|
}
|
2026-05-27 22:18:43 +08:00
|
|
|
handled, err := ConversationHumanDispatchService.TryOffHoursHandoffByAIWithRequestID(conversationID, aiAgent, reason, requestID)
|
2026-05-08 23:26:25 +08:00
|
|
|
if err != nil {
|
|
|
|
|
slog.Warn("off-hours ai handoff failed",
|
2026-05-27 22:18:43 +08:00
|
|
|
"requestId", requestID,
|
2026-05-08 23:26:25 +08:00
|
|
|
"conversation_id", conversationID,
|
|
|
|
|
"ai_agent_id", aiAgent.ID,
|
|
|
|
|
"error", err)
|
|
|
|
|
}
|
|
|
|
|
return handled, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 10:01:23 +08:00
|
|
|
func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error {
|
|
|
|
|
if operator == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.UnauthorizedI18n("error.auth.expired")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
return s.closeConversation(conversationID, enums.IMSenderTypeAgent, closeReason, operator)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 11:10:47 +08:00
|
|
|
func (s *conversationService) CloseCustomerConversation(conversationID int64, externalUser openidentity.ExternalUser) error {
|
2026-04-09 10:01:23 +08:00
|
|
|
conversation := s.Get(conversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-04-28 11:10:47 +08:00
|
|
|
if !s.IsCustomerConversationOwner(conversation, externalUser) {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.ForbiddenI18n("error.e0222")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
return s.closeConversation(conversationID, enums.IMSenderTypeCustomer, "", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error {
|
2026-08-28 22:23:13 +08:00
|
|
|
var queuedTeamID int64
|
|
|
|
|
var wasQueued bool
|
2026-04-09 10:01:23 +08:00
|
|
|
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
|
|
|
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if conversation.Status == enums.IMConversationStatusClosed {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-04-11 13:55:11 +08:00
|
|
|
if conversation.Status != enums.IMConversationStatusAIServing &&
|
|
|
|
|
conversation.Status != enums.IMConversationStatusPending &&
|
|
|
|
|
conversation.Status != enums.IMConversationStatusActive {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0197")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
wasQueued = conversation.Status == enums.IMConversationStatusPending && conversation.CurrentAssigneeID == 0
|
|
|
|
|
queuedTeamID = conversation.CurrentTeamID
|
2026-04-09 10:01:23 +08:00
|
|
|
var (
|
|
|
|
|
now = time.Now()
|
|
|
|
|
eventDesc = "会话已关闭"
|
|
|
|
|
operatorID int64
|
|
|
|
|
operatorName string
|
|
|
|
|
)
|
|
|
|
|
closeReason = strings.TrimSpace(closeReason)
|
|
|
|
|
if senderType == enums.IMSenderTypeCustomer {
|
|
|
|
|
eventDesc = "客户关闭会话"
|
|
|
|
|
} else {
|
|
|
|
|
if operator == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0226")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if closeReason == "" {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0128")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if !s.canCloseConversation(conversation, operator) {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.ForbiddenI18n("error.e0221")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
operatorID = operator.UserID
|
|
|
|
|
operatorName = operator.Nickname
|
|
|
|
|
}
|
|
|
|
|
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
|
|
|
|
"status": enums.IMConversationStatusClosed,
|
|
|
|
|
"closed_at": now,
|
|
|
|
|
"closed_by": operatorID,
|
|
|
|
|
"close_reason": closeReason,
|
|
|
|
|
"update_user_id": operatorID,
|
|
|
|
|
"update_user_name": operatorName,
|
|
|
|
|
"updated_at": now,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeClose, senderType, operatorID, eventDesc, s.buildEventPayload(map[string]any{
|
2026-08-28 22:23:13 +08:00
|
|
|
"from_status": conversation.Status,
|
|
|
|
|
"to_status": enums.IMConversationStatusClosed,
|
|
|
|
|
"from_assignee_id": conversation.CurrentAssigneeID,
|
|
|
|
|
"to_assignee_id": conversation.CurrentAssigneeID,
|
|
|
|
|
"close_reason": closeReason,
|
2026-04-09 10:01:23 +08:00
|
|
|
}))
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if conversation := s.Get(conversationID); conversation != nil {
|
|
|
|
|
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationClosed)
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
if wasQueued {
|
|
|
|
|
ConversationQueueService.PublishPoolUpdates(queuedTeamID)
|
|
|
|
|
}
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MarkAgentConversationReadToMessage 控制台客服将会话已读推进到指定消息。
|
|
|
|
|
func (s *conversationService) MarkAgentConversationReadToMessage(conversationID, messageID int64, operator *dto.AuthPrincipal) error {
|
|
|
|
|
if operator == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.UnauthorizedI18n("error.auth.expired")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
conversation := s.Get(conversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
changed, err := s.markConversationReadWithActor(conversation, messageID, agentConversationReadActor{operator: operator})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if changed {
|
|
|
|
|
if updated := s.Get(conversationID); updated != nil {
|
|
|
|
|
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationRead)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MarkCustomerConversationReadToMessage IM 客户将会话已读推进到指定消息(需为会话归属外部身份)。
|
2026-04-28 11:10:47 +08:00
|
|
|
func (s *conversationService) MarkCustomerConversationReadToMessage(conversationID, messageID int64, external *openidentity.ExternalUser) error {
|
2026-04-09 10:01:23 +08:00
|
|
|
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.UnauthorizedI18n("error.e0149")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
conversation := s.Get(conversationID)
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
if !s.IsCustomerConversationOwner(conversation, *external) {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.ForbiddenI18n("error.e0222")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
changed, err := s.markConversationReadWithActor(conversation, messageID, customerConversationReadActor{external: external})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if changed {
|
|
|
|
|
if updated := s.Get(conversationID); updated != nil {
|
|
|
|
|
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationRead)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 11:10:47 +08:00
|
|
|
func displayExternalName(ext *openidentity.ExternalUser) string {
|
2026-04-09 10:01:23 +08:00
|
|
|
if ext == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if n := strings.TrimSpace(ext.ExternalName); n != "" {
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(ext.ExternalID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// conversationReadActor 抽象「读者身份」,供 markConversationReadWithActor 共用(包内私有)。
|
|
|
|
|
type conversationReadActor interface {
|
|
|
|
|
isAgentSide() bool
|
|
|
|
|
getReadState(conversationID int64) *models.ConversationReadState
|
2026-05-09 22:05:34 +08:00
|
|
|
markRead(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message) error
|
2026-04-09 10:01:23 +08:00
|
|
|
conversationUpdateAudit() (userID int64, userName string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type agentConversationReadActor struct {
|
|
|
|
|
operator *dto.AuthPrincipal
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a agentConversationReadActor) isAgentSide() bool { return true }
|
|
|
|
|
|
|
|
|
|
func (a agentConversationReadActor) getReadState(conversationID int64) *models.ConversationReadState {
|
|
|
|
|
return ConversationReadStateService.GetByAgentReader(conversationID, a.operator)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 22:05:34 +08:00
|
|
|
func (a agentConversationReadActor) markRead(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message) error {
|
|
|
|
|
_, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, a.operator, targetMessage)
|
2026-04-09 10:01:23 +08:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a agentConversationReadActor) conversationUpdateAudit() (int64, string) {
|
|
|
|
|
if a.operator == nil {
|
|
|
|
|
return 0, ""
|
|
|
|
|
}
|
|
|
|
|
return a.operator.UserID, a.operator.Username
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type customerConversationReadActor struct {
|
2026-04-28 11:10:47 +08:00
|
|
|
external *openidentity.ExternalUser
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a customerConversationReadActor) isAgentSide() bool { return false }
|
|
|
|
|
|
|
|
|
|
func (a customerConversationReadActor) getReadState(conversationID int64) *models.ConversationReadState {
|
|
|
|
|
return ConversationReadStateService.GetByCustomerReader(conversationID, a.external)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 22:05:34 +08:00
|
|
|
func (a customerConversationReadActor) markRead(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message) error {
|
|
|
|
|
_, err := ConversationReadStateService.MarkCustomerRead(ctx, conversation, a.external, targetMessage)
|
2026-04-09 10:01:23 +08:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a customerConversationReadActor) conversationUpdateAudit() (int64, string) {
|
|
|
|
|
return 0, displayExternalName(a.external)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) markConversationReadWithActor(conversation *models.Conversation, messageID int64, actor conversationReadActor) (bool, error) {
|
|
|
|
|
if conversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return false, errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
targetMessage, err := MessageService.GetConversationReadTarget(conversation.ID, messageID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
if targetMessage == nil {
|
|
|
|
|
if actor.isAgentSide() && conversation.AgentUnreadCount == 0 {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
if !actor.isAgentSide() && conversation.CustomerUnreadCount == 0 {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
now := time.Now()
|
|
|
|
|
updateUserID, updateUserName := actor.conversationUpdateAudit()
|
|
|
|
|
updates := map[string]any{
|
|
|
|
|
"update_user_id": updateUserID,
|
|
|
|
|
"update_user_name": updateUserName,
|
|
|
|
|
"updated_at": now,
|
|
|
|
|
}
|
|
|
|
|
if actor.isAgentSide() {
|
|
|
|
|
updates["agent_unread_count"] = 0
|
|
|
|
|
} else {
|
|
|
|
|
updates["customer_unread_count"] = 0
|
|
|
|
|
}
|
|
|
|
|
return true, s.Updates(conversation.ID, updates)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentReadState := actor.getReadState(conversation.ID)
|
2026-06-24 22:53:17 +08:00
|
|
|
if currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID {
|
2026-04-09 10:01:23 +08:00
|
|
|
if actor.isAgentSide() && conversation.AgentUnreadCount == 0 {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
if !actor.isAgentSide() && conversation.CustomerUnreadCount == 0 {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
|
|
|
currentConversation := repositories.ConversationRepository.Get(ctx.Tx, conversation.ID)
|
|
|
|
|
if currentConversation == nil {
|
2026-06-02 20:51:13 +08:00
|
|
|
return errorsx.InvalidParamI18n("error.e0116")
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-05-09 22:05:34 +08:00
|
|
|
if err := actor.markRead(ctx, currentConversation, targetMessage); err != nil {
|
2026-04-09 10:01:23 +08:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, currentConversation.ID)
|
|
|
|
|
agentUnreadCount, err := s.countUnreadByState(ctx, currentConversation.ID, agentReadState, enums.IMSenderTypeCustomer)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
customerUnreadCount, err := s.countUnreadByState(ctx, currentConversation.ID, customerReadState, enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-06-24 22:53:17 +08:00
|
|
|
if actor.isAgentSide() && currentConversation.AgentUnreadCount == agentUnreadCount && currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID {
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-06-24 22:53:17 +08:00
|
|
|
if !actor.isAgentSide() && currentConversation.CustomerUnreadCount == customerUnreadCount && currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID {
|
2026-04-09 10:01:23 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
updateUserID, updateUserName := actor.conversationUpdateAudit()
|
|
|
|
|
return repositories.ConversationRepository.Updates(ctx.Tx, currentConversation.ID, map[string]any{
|
|
|
|
|
"agent_unread_count": agentUnreadCount,
|
|
|
|
|
"customer_unread_count": customerUnreadCount,
|
|
|
|
|
"update_user_id": updateUserID,
|
|
|
|
|
"update_user_name": updateUserName,
|
2026-05-09 22:05:34 +08:00
|
|
|
"updated_at": time.Now(),
|
2026-04-09 10:01:23 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
return true, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) countUnreadByState(ctx *sqls.TxContext, conversationID int64, state *models.ConversationReadState, senderTypes ...enums.IMSenderType) (int, error) {
|
2026-06-24 22:53:17 +08:00
|
|
|
lastReadMessageID := int64(0)
|
2026-04-09 10:01:23 +08:00
|
|
|
if state != nil {
|
2026-06-24 22:53:17 +08:00
|
|
|
lastReadMessageID = state.LastReadMessageID
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes))
|
|
|
|
|
for _, senderType := range senderTypes {
|
|
|
|
|
normalizedSenderTypes = append(normalizedSenderTypes, senderType)
|
|
|
|
|
}
|
2026-06-24 22:53:17 +08:00
|
|
|
count, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, lastReadMessageID, normalizedSenderTypes...)
|
2026-04-09 10:01:23 +08:00
|
|
|
return int(count), err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 11:10:47 +08:00
|
|
|
func (s *conversationService) IsCustomerConversationOwner(conversation *models.Conversation, externalUser openidentity.ExternalUser) bool {
|
2026-04-09 10:01:23 +08:00
|
|
|
if conversation == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-04-28 11:10:47 +08:00
|
|
|
extID := strings.TrimSpace(externalUser.ExternalID)
|
2026-08-28 22:23:13 +08:00
|
|
|
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" {
|
2026-04-09 10:01:23 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
return conversation.CustomerType == externalCustomerType(externalUser) &&
|
|
|
|
|
strings.TrimSpace(conversation.CustomerExternalID) == extID
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) BuildConversationSummary(conversation *models.Conversation) string {
|
|
|
|
|
if conversation == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
|
|
|
|
|
return conversation.LastMessageSummary
|
|
|
|
|
}
|
2026-04-27 19:27:24 +08:00
|
|
|
return strings.TrimSpace(conversation.CustomerName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
func externalCustomerType(externalUser openidentity.ExternalUser) string {
|
|
|
|
|
if externalUser.SubjectType != "" {
|
|
|
|
|
return string(externalUser.SubjectType)
|
2026-04-27 19:27:24 +08:00
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
return string(externalUser.ExternalSource)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) canCloseConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
|
|
|
|
|
if conversation == nil || operator == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if s.isAdmin(operator) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return conversation.Status == enums.IMConversationStatusActive && conversation.CurrentAssigneeID > 0 && conversation.CurrentAssigneeID == operator.UserID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) canTransferConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
|
|
|
|
|
if conversation == nil || operator == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if s.isAdmin(operator) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return conversation.Status == enums.IMConversationStatusActive &&
|
|
|
|
|
conversation.CurrentAssigneeID > 0 &&
|
|
|
|
|
conversation.CurrentAssigneeID == operator.UserID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) isAdmin(operator *dto.AuthPrincipal) bool {
|
|
|
|
|
if operator == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-08-21 00:41:07 +08:00
|
|
|
return operator.SubjectType == identity.SubjectAdmin
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *conversationService) buildEventPayload(payload map[string]any) string {
|
|
|
|
|
if len(payload) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
data, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return string(data)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 22:23:13 +08:00
|
|
|
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *openidentity.ExternalUser {
|
|
|
|
|
if conversation == nil || strings.TrimSpace(conversation.CustomerExternalID) == "" {
|
2026-04-27 19:04:10 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
external := &openidentity.ExternalUser{
|
|
|
|
|
ExternalID: strings.TrimSpace(conversation.CustomerExternalID),
|
|
|
|
|
ExternalName: strings.TrimSpace(conversation.CustomerName),
|
|
|
|
|
SubjectID: conversation.CustomerID,
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
switch conversation.CustomerType {
|
|
|
|
|
case string(identity.SubjectCard), string(identity.SubjectDevice), string(identity.SubjectMallUser):
|
|
|
|
|
external.ExternalSource = enums.ExternalSourceUser
|
|
|
|
|
external.SubjectType = identity.SubjectType(conversation.CustomerType)
|
2026-04-09 10:01:23 +08:00
|
|
|
default:
|
2026-08-28 22:23:13 +08:00
|
|
|
external.ExternalSource = enums.ExternalSource(conversation.CustomerType)
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
2026-08-28 22:23:13 +08:00
|
|
|
return external
|
2026-04-09 10:01:23 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 13:55:11 +08:00
|
|
|
func (s *conversationService) resolveInitialStatus(serviceMode enums.IMConversationServiceMode) enums.IMConversationStatus {
|
|
|
|
|
switch serviceMode {
|
|
|
|
|
case enums.IMConversationServiceModeHumanOnly:
|
|
|
|
|
return enums.IMConversationStatusPending
|
|
|
|
|
case enums.IMConversationServiceModeAIOnly, enums.IMConversationServiceModeAIFirst:
|
|
|
|
|
return enums.IMConversationStatusAIServing
|
|
|
|
|
default:
|
|
|
|
|
return enums.IMConversationStatusAIServing
|
|
|
|
|
}
|
|
|
|
|
}
|