refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -49,7 +50,11 @@ type dispatchPoolReport struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
var errConversationDispatchConflict = errors.New("conversation dispatch conflict")
|
||||
var (
|
||||
errConversationDispatchConflict = errors.New("conversation dispatch conflict")
|
||||
errDispatchCandidateUnavailable = errors.New("dispatch candidate unavailable")
|
||||
dispatchAssignmentMu sync.Mutex
|
||||
)
|
||||
|
||||
const pendingDispatchBatchLimit = 50
|
||||
|
||||
@@ -66,11 +71,17 @@ func (s *conversationDispatchService) DispatchConversation(conversationID int64)
|
||||
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, nil
|
||||
if conversation.CurrentTeamID > 0 {
|
||||
return s.dispatchPendingConversationForTeams(conversation, []int64{conversation.CurrentTeamID}, conversation.AIAgentID)
|
||||
}
|
||||
return s.DispatchPendingConversation(conversation, aiAgent)
|
||||
if conversation.AIAgentID > 0 {
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, nil
|
||||
}
|
||||
return s.DispatchPendingConversation(conversation, aiAgent)
|
||||
}
|
||||
return s.dispatchPendingConversationForTeams(conversation, s.findAllActiveScheduleTeamIDs(time.Now()), 0)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) DispatchPendingConversation(conversation *models.Conversation, aiAgent *models.AIAgent) (*models.Conversation, error) {
|
||||
@@ -82,10 +93,20 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
}
|
||||
|
||||
teamIDs := utils.SplitInt64s(aiAgent.TeamIDs)
|
||||
if conversation.CurrentTeamID > 0 {
|
||||
teamIDs = []int64{conversation.CurrentTeamID}
|
||||
}
|
||||
return s.dispatchPendingConversationForTeams(conversation, teamIDs, aiAgent.ID)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) dispatchPendingConversationForTeams(conversation *models.Conversation, teamIDs []int64, aiAgentID int64) (*models.Conversation, error) {
|
||||
if conversation == nil || conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(teamIDs) == 0 {
|
||||
slog.Debug("skip auto dispatch due to empty ai agent team ids",
|
||||
slog.Debug("skip auto dispatch due to empty dispatch team ids",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -97,7 +118,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
if len(candidates) == 0 {
|
||||
slog.Debug("no dispatch candidate available",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
"requested_team_ids", report.RequestedTeamIDs,
|
||||
"active_schedule_team_ids", report.ActiveScheduleTeams,
|
||||
"matched_profiles", report.MatchedProfiles,
|
||||
@@ -110,6 +131,9 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
for _, candidate := range candidates {
|
||||
dispatched, err := s.tryAssignConversation(conversation.ID, candidate.profile, "自动分配")
|
||||
if err != nil {
|
||||
if errors.Is(err, errDispatchCandidateUnavailable) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, errConversationDispatchConflict) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -118,7 +142,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
if dispatched != nil {
|
||||
slog.Info("conversation auto dispatched",
|
||||
"conversation_id", dispatched.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
"assignee_id", dispatched.CurrentAssigneeID,
|
||||
"team_id", dispatched.CurrentTeamID,
|
||||
"candidate_count", report.CandidateCount,
|
||||
@@ -137,7 +161,7 @@ func (s *conversationDispatchService) DispatchPendingConversation(conversation *
|
||||
}
|
||||
slog.Debug("auto dispatch candidate list exhausted without assignment",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"ai_agent_id", aiAgentID,
|
||||
"candidate_count", report.CandidateCount,
|
||||
)
|
||||
return nil, nil
|
||||
@@ -155,11 +179,18 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
|
||||
conversations := ConversationService.Find(sqls.NewCnd().
|
||||
Eq("status", enums.IMConversationStatusPending).
|
||||
Eq("current_assignee_id", 0).
|
||||
Desc("id"))
|
||||
Asc("id"))
|
||||
if len(conversations) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ConversationQueueService.Sort(conversations, now)
|
||||
poolIDs := make(map[int64]struct{})
|
||||
for _, conversation := range conversations {
|
||||
poolIDs[conversation.CurrentTeamID] = struct{}{}
|
||||
}
|
||||
|
||||
dispatchedCount := 0
|
||||
scannedCount := 0
|
||||
for i, conversation := range conversations {
|
||||
@@ -182,6 +213,9 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
|
||||
"limit", limit,
|
||||
)
|
||||
}
|
||||
for teamID := range poolIDs {
|
||||
ConversationQueueService.PublishPoolUpdates(teamID)
|
||||
}
|
||||
return dispatchedCount, nil
|
||||
}
|
||||
|
||||
@@ -379,6 +413,18 @@ func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64,
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findAllActiveScheduleTeamIDs(now time.Time) []int64 {
|
||||
if !sqls.DB().Migrator().HasTable(&models.AgentTeam{}) || !sqls.DB().Migrator().HasTable(&models.AgentTeamSchedule{}) {
|
||||
return nil
|
||||
}
|
||||
teams := AgentTeamService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("id"))
|
||||
teamIDs := make([]int64, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
teamIDs = append(teamIDs, team.ID)
|
||||
}
|
||||
return s.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
|
||||
ret := make(map[int64]int, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
@@ -404,8 +450,12 @@ func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []i
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) tryAssignConversation(conversationID int64, candidate models.AgentProfile, reason string) (*models.Conversation, error) {
|
||||
dispatchAssignmentMu.Lock()
|
||||
defer dispatchAssignmentMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
operator := systemDispatchPrincipal()
|
||||
var previousTeamID int64
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
@@ -415,6 +465,25 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return errConversationDispatchConflict
|
||||
}
|
||||
previousTeamID = conversation.CurrentTeamID
|
||||
|
||||
var currentProfile models.AgentProfile
|
||||
if err := ctx.Tx.Where("id = ?", candidate.ID).First(¤tProfile).Error; err != nil {
|
||||
return errDispatchCandidateUnavailable
|
||||
}
|
||||
if currentProfile.Status != enums.StatusOk || !currentProfile.AutoAssignEnabled || currentProfile.ServiceStatus != enums.ServiceStatusIdle || currentProfile.UserID != candidate.UserID {
|
||||
return errDispatchCandidateUnavailable
|
||||
}
|
||||
var activeCount int64
|
||||
if err := ctx.Tx.Model(&models.Conversation{}).
|
||||
Where("status = ? AND current_assignee_id = ?", enums.IMConversationStatusActive, currentProfile.UserID).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if currentProfile.MaxConcurrentCount > 0 && activeCount >= int64(currentProfile.MaxConcurrentCount) {
|
||||
return errDispatchCandidateUnavailable
|
||||
}
|
||||
candidate = currentProfile
|
||||
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
@@ -445,17 +514,19 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ConversationService.Get(conversationID), nil
|
||||
dispatched := ConversationService.Get(conversationID)
|
||||
ConversationQueueService.PublishPoolUpdates(previousTeamID)
|
||||
return dispatched, nil
|
||||
}
|
||||
|
||||
func buildDispatchEventPayload(fromAssigneeID, toAssigneeID, toTeamID int64, reason string) string {
|
||||
return ConversationService.buildEventPayload(map[string]any{
|
||||
"fromStatus": enums.IMConversationStatusPending,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": fromAssigneeID,
|
||||
"toAssigneeId": toAssigneeID,
|
||||
"toTeamId": toTeamID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
"from_status": enums.IMConversationStatusPending,
|
||||
"to_status": enums.IMConversationStatusActive,
|
||||
"from_assignee_id": fromAssigneeID,
|
||||
"to_assignee_id": toAssigneeID,
|
||||
"to_team_id": toTeamID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user