refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
@@ -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(&currentProfile).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),
})
}