refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -84,12 +84,15 @@ func (s *conversationService) Updates(id int64, columns map[string]interface{})
|
||||
return repositories.ConversationRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, customerID int64) *models.Conversation {
|
||||
if customerID <= 0 {
|
||||
func (s *conversationService) getLatestNotFinishedByExternalUser(db *gorm.DB, externalUser openidentity.ExternalUser, channelID int64) *models.Conversation {
|
||||
externalID := strings.TrimSpace(externalUser.ExternalID)
|
||||
if externalID == "" || channelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
cnd := sqls.NewCnd()
|
||||
cnd.Eq("customer_id", customerID)
|
||||
cnd.Eq("channel_id", channelID)
|
||||
cnd.Eq("customer_type", externalCustomerType(externalUser))
|
||||
cnd.Eq("customer_external_id", externalID)
|
||||
cnd.In("status", []enums.IMConversationStatus{
|
||||
enums.IMConversationStatusAIServing,
|
||||
enums.IMConversationStatusPending,
|
||||
@@ -113,40 +116,67 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
var conversation *models.Conversation
|
||||
var welcomeMessage *models.Message
|
||||
created := false
|
||||
reconfigured := false
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
|
||||
if err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
customerName := s.getCustomerName(ctx.Tx, customerID)
|
||||
if existing := s.getLatestNotFinishedByCustomerID(ctx.Tx, customerID); existing != nil {
|
||||
if existing != nil {
|
||||
conversation = existing
|
||||
updates := make(map[string]any)
|
||||
if customerName != "" && existing.CustomerName != customerName {
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, existing.ID, map[string]any{
|
||||
"customer_name": customerName,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
conversation.CustomerName = customerName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
created = true
|
||||
now := time.Now()
|
||||
conversation = &models.Conversation{
|
||||
AIAgentID: aiAgentID,
|
||||
ChannelID: channelID,
|
||||
CustomerID: customerID,
|
||||
CustomerName: customerName,
|
||||
Status: s.resolveInitialStatus(serviceMode),
|
||||
ServiceMode: serviceMode,
|
||||
Priority: 0,
|
||||
CurrentAssigneeID: 0,
|
||||
CurrentTeamID: 0,
|
||||
LastMessageAt: now,
|
||||
LastActiveAt: now,
|
||||
AuditFields: utils.BuildAuditFields(nil),
|
||||
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),
|
||||
}
|
||||
if err := ctx.Tx.Create(conversation).Error; err != nil {
|
||||
return err
|
||||
@@ -158,8 +188,9 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
return err
|
||||
}
|
||||
if aiAgent != nil {
|
||||
welcomeMessage, err = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
|
||||
return err
|
||||
var welcomeErr error
|
||||
welcomeMessage, welcomeErr = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
|
||||
return welcomeErr
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -169,7 +200,10 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
|
||||
return nil, errorsx.BusinessErrorI18n(1, "error.conversation.createFailed")
|
||||
}
|
||||
if !created {
|
||||
return conversation, nil
|
||||
if reconfigured {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
return s.Get(conversation.ID), nil
|
||||
}
|
||||
|
||||
// 推送会话创建事件
|
||||
@@ -204,6 +238,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
return errorsx.InvalidParamI18n("error.e0276")
|
||||
}
|
||||
var assignedEvent events.ConversationAssignedEvent
|
||||
var previousTeamID int64
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
|
||||
if conversation == nil {
|
||||
@@ -212,6 +247,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
if conversation.Status != enums.IMConversationStatusPending {
|
||||
return errorsx.InvalidParamI18n("error.e0135")
|
||||
}
|
||||
previousTeamID = conversation.CurrentTeamID
|
||||
now := time.Now()
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
|
||||
return err
|
||||
@@ -221,6 +257,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
|
||||
"current_assignee_id": req.AssigneeID,
|
||||
"current_team_id": targetProfile.TeamID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
@@ -229,11 +266,12 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
return err
|
||||
}
|
||||
if err := ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": req.AssigneeID,
|
||||
"reason": strings.TrimSpace(req.Reason),
|
||||
"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),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,6 +290,7 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
|
||||
if conversation := s.Get(req.ConversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned)
|
||||
}
|
||||
ConversationQueueService.PublishPoolUpdates(previousTeamID)
|
||||
eventbus.PublishAsync(context.Background(), assignedEvent)
|
||||
return nil
|
||||
}
|
||||
@@ -272,15 +311,25 @@ func (s *conversationService) AutoAssignConversation(conversationID int64, opera
|
||||
return errorsx.InvalidParamI18n("error.e0190")
|
||||
}
|
||||
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0003")
|
||||
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
|
||||
}
|
||||
result, err := ConversationHumanDispatchService.DispatchPendingConversation(conversationID, *aiAgent)
|
||||
result, err := ConversationDispatchService.DispatchConversation(conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil || result.Decision == HandoffDecisionOffHours {
|
||||
if result == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0194")
|
||||
}
|
||||
return nil
|
||||
@@ -332,11 +381,11 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
|
||||
return err
|
||||
}
|
||||
if err := ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "会话已转接", s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": toUserID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
"from_status": conversation.Status,
|
||||
"to_status": enums.IMConversationStatusActive,
|
||||
"from_assignee_id": conversation.CurrentAssigneeID,
|
||||
"to_assignee_id": toUserID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -416,6 +465,8 @@ func (s *conversationService) CloseCustomerConversation(conversationID int64, ex
|
||||
}
|
||||
|
||||
func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error {
|
||||
var queuedTeamID int64
|
||||
var wasQueued bool
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
@@ -429,6 +480,8 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
|
||||
conversation.Status != enums.IMConversationStatusActive {
|
||||
return errorsx.InvalidParamI18n("error.e0197")
|
||||
}
|
||||
wasQueued = conversation.Status == enums.IMConversationStatusPending && conversation.CurrentAssigneeID == 0
|
||||
queuedTeamID = conversation.CurrentTeamID
|
||||
var (
|
||||
now = time.Now()
|
||||
eventDesc = "会话已关闭"
|
||||
@@ -466,11 +519,11 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeClose, senderType, operatorID, eventDesc, s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusClosed,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": conversation.CurrentAssigneeID,
|
||||
"closeReason": closeReason,
|
||||
"from_status": conversation.Status,
|
||||
"to_status": enums.IMConversationStatusClosed,
|
||||
"from_assignee_id": conversation.CurrentAssigneeID,
|
||||
"to_assignee_id": conversation.CurrentAssigneeID,
|
||||
"close_reason": closeReason,
|
||||
}))
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -478,6 +531,9 @@ func (s *conversationService) closeConversation(conversationID int64, senderType
|
||||
if conversation := s.Get(conversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationClosed)
|
||||
}
|
||||
if wasQueued {
|
||||
ConversationQueueService.PublishPoolUpdates(queuedTeamID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -681,14 +737,11 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
|
||||
return false
|
||||
}
|
||||
extID := strings.TrimSpace(externalUser.ExternalID)
|
||||
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
|
||||
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" {
|
||||
return false
|
||||
}
|
||||
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID)
|
||||
if identity == nil {
|
||||
return false
|
||||
}
|
||||
return identity.CustomerID == conversation.CustomerID
|
||||
return conversation.CustomerType == externalCustomerType(externalUser) &&
|
||||
strings.TrimSpace(conversation.CustomerExternalID) == extID
|
||||
}
|
||||
|
||||
func (s *conversationService) BuildConversationSummary(conversation *models.Conversation) string {
|
||||
@@ -701,14 +754,11 @@ func (s *conversationService) BuildConversationSummary(conversation *models.Conv
|
||||
return strings.TrimSpace(conversation.CustomerName)
|
||||
}
|
||||
|
||||
func (s *conversationService) getCustomerName(db *gorm.DB, customerID int64) string {
|
||||
if customerID <= 0 {
|
||||
return ""
|
||||
func externalCustomerType(externalUser openidentity.ExternalUser) string {
|
||||
if externalUser.SubjectType != "" {
|
||||
return string(externalUser.SubjectType)
|
||||
}
|
||||
if customer := repositories.CustomerRepository.Get(db, customerID); customer != nil {
|
||||
return strings.TrimSpace(customer.Name)
|
||||
}
|
||||
return ""
|
||||
return string(externalUser.ExternalSource)
|
||||
}
|
||||
|
||||
func (s *conversationService) canCloseConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
@@ -751,101 +801,23 @@ func (s *conversationService) buildEventPayload(payload map[string]any) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// LinkConversationCustomer 将会话绑定到指定客户。
|
||||
func (s *conversationService) LinkConversationCustomer(conversationID, customerID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if conversationID <= 0 || customerID <= 0 {
|
||||
return errorsx.InvalidParamI18n("error.e0133")
|
||||
}
|
||||
cust := CustomerService.Get(customerID)
|
||||
if cust == nil || cust.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParamI18n("error.e0155")
|
||||
}
|
||||
conv := s.Get(conversationID)
|
||||
if conv == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if conv.Status == enums.IMConversationStatusClosed {
|
||||
return errorsx.InvalidParamI18n("error.e0183")
|
||||
}
|
||||
if !s.canLinkConversationCustomer(conv, operator) {
|
||||
return errorsx.ForbiddenI18n("error.e0224")
|
||||
}
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
current := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"customer_id": customerID,
|
||||
"customer_name": strings.TrimSpace(cust.Name),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated := s.Get(conversationID); updated != nil {
|
||||
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *models.CustomerIdentity {
|
||||
if conversation == nil || conversation.CustomerID <= 0 {
|
||||
func (s *conversationService) GetConversationExternalIdentity(conversation *models.Conversation) *openidentity.ExternalUser {
|
||||
if conversation == nil || strings.TrimSpace(conversation.CustomerExternalID) == "" {
|
||||
return nil
|
||||
}
|
||||
identities := repositories.CustomerIdentityRepository.FindByCustomerID(sqls.DB(), conversation.CustomerID)
|
||||
if len(identities) == 0 {
|
||||
return nil
|
||||
external := &openidentity.ExternalUser{
|
||||
ExternalID: strings.TrimSpace(conversation.CustomerExternalID),
|
||||
ExternalName: strings.TrimSpace(conversation.CustomerName),
|
||||
SubjectID: conversation.CustomerID,
|
||||
}
|
||||
if channel := ChannelService.Get(conversation.ChannelID); channel != nil {
|
||||
expected := externalSourceForChannelType(channel.ChannelType)
|
||||
if strings.TrimSpace(string(expected)) != "" {
|
||||
for i := range identities {
|
||||
if identities[i].ExternalSource == expected {
|
||||
return &identities[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &identities[0]
|
||||
}
|
||||
|
||||
func externalSourceForChannelType(channelType string) enums.ExternalSource {
|
||||
switch strings.TrimSpace(channelType) {
|
||||
case enums.ChannelTypeWxWorkKF:
|
||||
return enums.ExternalSourceWxWorkKF
|
||||
case enums.ChannelTypeWeb:
|
||||
return enums.ExternalSourceGuest
|
||||
switch conversation.CustomerType {
|
||||
case string(identity.SubjectCard), string(identity.SubjectDevice), string(identity.SubjectMallUser):
|
||||
external.ExternalSource = enums.ExternalSourceUser
|
||||
external.SubjectType = identity.SubjectType(conversation.CustomerType)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conversationService) canLinkConversationCustomer(conv *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
if conv == nil || operator == nil {
|
||||
return false
|
||||
}
|
||||
if s.isAdmin(operator) {
|
||||
return true
|
||||
}
|
||||
switch conv.Status {
|
||||
case enums.IMConversationStatusAIServing:
|
||||
return true
|
||||
case enums.IMConversationStatusPending:
|
||||
return true
|
||||
case enums.IMConversationStatusActive:
|
||||
return conv.CurrentAssigneeID == 0 || conv.CurrentAssigneeID == operator.UserID
|
||||
default:
|
||||
return false
|
||||
external.ExternalSource = enums.ExternalSource(conversation.CustomerType)
|
||||
}
|
||||
return external
|
||||
}
|
||||
|
||||
func (s *conversationService) resolveInitialStatus(serviceMode enums.IMConversationServiceMode) enums.IMConversationStatus {
|
||||
|
||||
Reference in New Issue
Block a user