Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
408 lines
12 KiB
Go
408 lines
12 KiB
Go
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
|
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"agent-desk/internal/events"
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/dto"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/eventbus"
|
|
"agent-desk/internal/repositories"
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
|
)
|
|
|
|
var ConversationDispatchService = newConversationDispatchService()
|
|
|
|
func newConversationDispatchService() *conversationDispatchService {
|
|
return &conversationDispatchService{}
|
|
}
|
|
|
|
type conversationDispatchService struct{}
|
|
|
|
type dispatchCandidate struct {
|
|
profile models.AgentProfile
|
|
activeCount int
|
|
loadRate float64
|
|
}
|
|
|
|
type agentActiveConversationCount struct {
|
|
CurrentAssigneeID int64 `gorm:"column:current_assignee_id"`
|
|
ActiveCount int `gorm:"column:active_count"`
|
|
}
|
|
|
|
type dispatchPoolReport struct {
|
|
RequestedTeamIDs []int64
|
|
ActiveScheduleTeams []int64
|
|
MatchedProfiles int
|
|
EligibleProfiles int
|
|
CandidateCount int
|
|
Reason string
|
|
}
|
|
|
|
var errConversationDispatchConflict = errors.New("conversation dispatch conflict")
|
|
|
|
const pendingDispatchBatchLimit = 50
|
|
|
|
var pendingDispatchRunning atomic.Bool
|
|
|
|
func (s *conversationDispatchService) DispatchConversation(conversationID int64) (*models.Conversation, error) {
|
|
if conversationID <= 0 {
|
|
return nil, nil
|
|
}
|
|
conversation := ConversationService.Get(conversationID)
|
|
if conversation == nil {
|
|
return nil, nil
|
|
}
|
|
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
|
|
}
|
|
return s.DispatchPendingConversation(conversation, aiAgent)
|
|
}
|
|
|
|
func (s *conversationDispatchService) DispatchPendingConversation(conversation *models.Conversation, aiAgent *models.AIAgent) (*models.Conversation, error) {
|
|
if conversation == nil || aiAgent == nil {
|
|
return nil, nil
|
|
}
|
|
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
teamIDs := resolveDispatchTeamIDs(aiAgent.TeamIDs)
|
|
if len(teamIDs) == 0 {
|
|
slog.Debug("skip auto dispatch due to empty ai agent team ids",
|
|
"conversation_id", conversation.ID,
|
|
"ai_agent_id", aiAgent.ID,
|
|
)
|
|
return nil, nil
|
|
}
|
|
|
|
candidates, report, err := s.pickDispatchCandidates(teamIDs, time.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(candidates) == 0 {
|
|
slog.Debug("no dispatch candidate available",
|
|
"conversation_id", conversation.ID,
|
|
"ai_agent_id", aiAgent.ID,
|
|
"requested_team_ids", report.RequestedTeamIDs,
|
|
"active_schedule_team_ids", report.ActiveScheduleTeams,
|
|
"matched_profiles", report.MatchedProfiles,
|
|
"eligible_profiles", report.EligibleProfiles,
|
|
"reason", report.Reason,
|
|
)
|
|
return nil, nil
|
|
}
|
|
|
|
for _, candidate := range candidates {
|
|
dispatched, err := s.tryAssignConversation(conversation.ID, candidate.profile, "自动分配")
|
|
if err != nil {
|
|
if errors.Is(err, errConversationDispatchConflict) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if dispatched != nil {
|
|
slog.Info("conversation auto dispatched",
|
|
"conversation_id", dispatched.ID,
|
|
"ai_agent_id", aiAgent.ID,
|
|
"assignee_id", dispatched.CurrentAssigneeID,
|
|
"team_id", dispatched.CurrentTeamID,
|
|
"candidate_count", report.CandidateCount,
|
|
"requested_team_ids", report.RequestedTeamIDs,
|
|
)
|
|
WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned)
|
|
eventbus.PublishAsync(context.Background(), events.ConversationAssignedEvent{
|
|
ConversationID: dispatched.ID,
|
|
ToUserID: dispatched.CurrentAssigneeID,
|
|
OperatorID: systemDispatchPrincipal().UserID,
|
|
Reason: "自动分配",
|
|
AssignType: events.ConversationAssignTypeAutoAssign,
|
|
})
|
|
return dispatched, nil
|
|
}
|
|
}
|
|
slog.Debug("auto dispatch candidate list exhausted without assignment",
|
|
"conversation_id", conversation.ID,
|
|
"ai_agent_id", aiAgent.ID,
|
|
"candidate_count", report.CandidateCount,
|
|
)
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *conversationDispatchService) DispatchPendingConversations(limit int) (int, error) {
|
|
if !pendingDispatchRunning.CompareAndSwap(false, true) {
|
|
return 0, nil
|
|
}
|
|
defer pendingDispatchRunning.Store(false)
|
|
|
|
if limit <= 0 {
|
|
limit = pendingDispatchBatchLimit
|
|
}
|
|
// Preserve the existing newest-first batch/periodic retry policy, but
|
|
// bound the SQL read and distinguish failure from an empty queue.
|
|
conversations, err := repositories.ConversationRepository.FindPendingDispatchBatch(sqls.DB(), limit)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(conversations) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
dispatchedCount := 0
|
|
scannedCount := 0
|
|
for i, conversation := range conversations {
|
|
if i >= limit {
|
|
break
|
|
}
|
|
scannedCount++
|
|
dispatched, err := s.DispatchConversation(conversation.ID)
|
|
if err != nil {
|
|
return dispatchedCount, err
|
|
}
|
|
if dispatched != nil {
|
|
dispatchedCount++
|
|
}
|
|
}
|
|
if scannedCount > 0 {
|
|
slog.Info("pending conversation dispatch scan completed",
|
|
"scanned_count", scannedCount,
|
|
"dispatched_count", dispatchedCount,
|
|
"limit", limit,
|
|
)
|
|
}
|
|
return dispatchedCount, nil
|
|
}
|
|
|
|
func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Duration) {
|
|
if interval <= 0 {
|
|
interval = 30 * time.Second
|
|
}
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
slog.Info("pending conversation dispatch loop started",
|
|
"interval_seconds", int(interval/time.Second),
|
|
)
|
|
|
|
for {
|
|
_ = repositories.SupportAvailabilityRepository.PrunePresence(sqls.DB(), time.Now())
|
|
if _, err := s.DispatchPendingConversations(0); err != nil {
|
|
slog.Warn("dispatch pending conversations loop failed", "error", err)
|
|
}
|
|
<-ticker.C
|
|
}
|
|
}()
|
|
}
|
|
|
|
// pickDispatchCandidates returns the eligible dispatch candidates for the given teamIDs at the given time, along with a report for debugging and analysis.
|
|
func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, now time.Time) ([]dispatchCandidate, dispatchPoolReport, error) {
|
|
report := dispatchPoolReport{
|
|
RequestedTeamIDs: append([]int64(nil), teamIDs...),
|
|
}
|
|
|
|
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
|
|
if err != nil {
|
|
return nil, report, err
|
|
}
|
|
report.MatchedProfiles = len(profiles)
|
|
report.ActiveScheduleTeams = s.findActiveScheduleTeamIDs(teamIDs, now)
|
|
candidates := make([]dispatchCandidate, 0, len(profiles))
|
|
report.Reason = "no_eligible_operator"
|
|
for _, profile := range profiles {
|
|
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), profile, true, now, 0)
|
|
if err != nil {
|
|
return nil, report, err
|
|
}
|
|
if !eligibility.Eligible {
|
|
report.Reason = eligibility.Reason
|
|
continue
|
|
}
|
|
report.EligibleProfiles++
|
|
loadRate := float64(eligibility.ActiveCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
|
|
candidates = append(candidates, dispatchCandidate{profile: profile, activeCount: eligibility.ActiveCount, loadRate: loadRate})
|
|
}
|
|
report.CandidateCount = len(candidates)
|
|
if len(candidates) == 0 {
|
|
return nil, report, nil
|
|
}
|
|
|
|
slices.SortFunc(candidates, func(a, b dispatchCandidate) int {
|
|
switch {
|
|
case a.loadRate < b.loadRate:
|
|
return -1
|
|
case a.loadRate > b.loadRate:
|
|
return 1
|
|
}
|
|
switch {
|
|
case a.activeCount < b.activeCount:
|
|
return -1
|
|
case a.activeCount > b.activeCount:
|
|
return 1
|
|
}
|
|
switch {
|
|
case a.profile.PriorityLevel > b.profile.PriorityLevel:
|
|
return -1
|
|
case a.profile.PriorityLevel < b.profile.PriorityLevel:
|
|
return 1
|
|
}
|
|
aLastStatusAt := zeroTime(a.profile.LastStatusAt)
|
|
bLastStatusAt := zeroTime(b.profile.LastStatusAt)
|
|
switch {
|
|
case aLastStatusAt.Before(bLastStatusAt):
|
|
return -1
|
|
case aLastStatusAt.After(bLastStatusAt):
|
|
return 1
|
|
}
|
|
switch {
|
|
case a.profile.UserID < b.profile.UserID:
|
|
return -1
|
|
case a.profile.UserID > b.profile.UserID:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
report.Reason = "ok"
|
|
return candidates, report, nil
|
|
}
|
|
|
|
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
|
|
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
|
|
result := make([]int64, 0, len(teamIDs))
|
|
for _, teamID := range uniquePositiveInt64s(teamIDs) {
|
|
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), teamID)
|
|
if err != nil || team == nil || team.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), teamID, now)
|
|
if err == nil && calendar.Open {
|
|
result = append(result, teamID)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
|
|
ret := make(map[int64]int, len(userIDs))
|
|
if len(userIDs) == 0 {
|
|
return ret, nil
|
|
}
|
|
|
|
rows := make([]agentActiveConversationCount, 0)
|
|
if err := sqls.DB().
|
|
Model(&models.Conversation{}).
|
|
Select("current_assignee_id, COUNT(1) AS active_count").
|
|
Where("status = ? AND current_assignee_id IN ?", enums.IMConversationStatusActive, userIDs).
|
|
Group("current_assignee_id").
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range rows {
|
|
if row.CurrentAssigneeID <= 0 {
|
|
continue
|
|
}
|
|
ret[row.CurrentAssigneeID] = row.ActiveCount
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func (s *conversationDispatchService) tryAssignConversation(conversationID int64, candidate models.AgentProfile, reason string) (*models.Conversation, error) {
|
|
now := time.Now()
|
|
operator := systemDispatchPrincipal()
|
|
|
|
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, candidate.UserID); err != nil {
|
|
return err
|
|
}
|
|
|
|
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
|
if conversation == nil {
|
|
return errConversationDispatchConflict
|
|
}
|
|
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
|
return errConversationDispatchConflict
|
|
}
|
|
|
|
guarded, guardErr := SupportAvailabilityService.GuardAssignment(ctx.Tx, candidate.UserID, true, conversation, time.Now())
|
|
if guardErr != nil {
|
|
return guardErr
|
|
}
|
|
candidate = *guarded
|
|
|
|
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
|
return err
|
|
}
|
|
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, candidate.UserID, enums.IMAssignmentTypeAssign, reason, operator, now); err != nil {
|
|
return err
|
|
}
|
|
|
|
result := ctx.Tx.Model(&models.Conversation{}).
|
|
Where("id = ? AND status = ? AND current_assignee_id = ?", conversationID, enums.IMConversationStatusPending, 0).
|
|
Updates(map[string]any{
|
|
"current_assignee_id": candidate.UserID,
|
|
"current_team_id": candidate.TeamID,
|
|
"status": enums.IMConversationStatusActive,
|
|
"update_user_id": operator.UserID,
|
|
"update_user_name": operator.Username,
|
|
"updated_at": now,
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return errConversationDispatchConflict
|
|
}
|
|
|
|
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeAssign, enums.IMSenderTypeSystem, operator.UserID, "会话已自动分配", buildDispatchEventPayload(conversation.CurrentAssigneeID, candidate.UserID, candidate.TeamID, reason))
|
|
})
|
|
if errors.Is(err, errSupportAssignmentIneligible) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ConversationService.Get(conversationID), 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),
|
|
})
|
|
}
|
|
|
|
func systemDispatchPrincipal() *dto.AuthPrincipal {
|
|
return &dto.AuthPrincipal{
|
|
UserID: 0,
|
|
Username: "system",
|
|
Nickname: "system",
|
|
}
|
|
}
|
|
|
|
func zeroTime(value *time.Time) time.Time {
|
|
if value == nil {
|
|
return time.Time{}
|
|
}
|
|
return *value
|
|
}
|