feat: harden support, localize knowledge and add operator availability [skip ci]
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.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -151,10 +153,12 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
|
||||
if limit <= 0 {
|
||||
limit = pendingDispatchBatchLimit
|
||||
}
|
||||
conversations := ConversationService.Find(sqls.NewCnd().
|
||||
Eq("status", enums.IMConversationStatusPending).
|
||||
Eq("current_assignee_id", 0).
|
||||
Desc("id"))
|
||||
// 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
|
||||
}
|
||||
@@ -197,6 +201,7 @@ func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Durat
|
||||
)
|
||||
|
||||
for {
|
||||
_ = repositories.SupportAvailabilityRepository.PrunePresence(sqls.DB(), time.Now())
|
||||
if _, err := s.DispatchPendingConversations(0); err != nil {
|
||||
slog.Warn("dispatch pending conversations loop failed", "error", err)
|
||||
}
|
||||
@@ -211,50 +216,29 @@ func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, no
|
||||
RequestedTeamIDs: append([]int64(nil), teamIDs...),
|
||||
}
|
||||
|
||||
// 1. filter teams with active schedule
|
||||
activeTeamIDs := s.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
report.ActiveScheduleTeams = activeTeamIDs
|
||||
if len(activeTeamIDs) == 0 {
|
||||
report.Reason = "no_active_schedule_team"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
// 2. find agent profiles for the active teams
|
||||
profiles := AgentProfileService.GetDispatchAgents(activeTeamIDs)
|
||||
report.MatchedProfiles = len(profiles)
|
||||
if len(profiles) == 0 {
|
||||
report.Reason = "no_matched_profile"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
enabledProfiles, enabledUserIDs, reason := s.filterEnabledDispatchProfiles(profiles)
|
||||
if reason != "" {
|
||||
report.Reason = reason
|
||||
return nil, report, nil
|
||||
}
|
||||
report.EligibleProfiles = len(enabledProfiles)
|
||||
|
||||
activeCounts, err := s.findActiveConversationCountMap(enabledUserIDs)
|
||||
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
|
||||
if err != nil {
|
||||
return nil, report, err
|
||||
}
|
||||
|
||||
candidates := make([]dispatchCandidate, 0, len(enabledProfiles))
|
||||
for _, profile := range enabledProfiles {
|
||||
activeCount := activeCounts[profile.UserID]
|
||||
if profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount {
|
||||
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
|
||||
}
|
||||
loadRate := float64(activeCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
|
||||
candidates = append(candidates, dispatchCandidate{
|
||||
profile: profile,
|
||||
activeCount: activeCount,
|
||||
loadRate: loadRate,
|
||||
})
|
||||
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 {
|
||||
report.Reason = "all_candidates_at_capacity"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
@@ -298,86 +282,20 @@ func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, no
|
||||
return candidates, report, nil
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) filterEnabledDispatchProfiles(profiles []models.AgentProfile) ([]models.AgentProfile, []int64, string) {
|
||||
userIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if profile.UserID > 0 {
|
||||
userIDs = append(userIDs, profile.UserID)
|
||||
}
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil, "no_profile_with_capacity_config"
|
||||
}
|
||||
|
||||
enabledUsers := UserService.Find(sqls.NewCnd().
|
||||
In("id", userIDs).
|
||||
Eq("status", enums.StatusOk))
|
||||
if len(enabledUsers) == 0 {
|
||||
return nil, nil, "no_enabled_user"
|
||||
}
|
||||
|
||||
enabledUserSet := make(map[int64]struct{}, len(enabledUsers))
|
||||
for _, user := range enabledUsers {
|
||||
enabledUserSet[user.ID] = struct{}{}
|
||||
}
|
||||
|
||||
enabledProfiles := make([]models.AgentProfile, 0, len(profiles))
|
||||
enabledUserIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if _, exists := enabledUserSet[profile.UserID]; !exists {
|
||||
continue
|
||||
}
|
||||
enabledProfiles = append(enabledProfiles, profile)
|
||||
enabledUserIDs = append(enabledUserIDs, profile.UserID)
|
||||
}
|
||||
if len(enabledProfiles) == 0 {
|
||||
return nil, nil, "no_profile_for_enabled_user"
|
||||
}
|
||||
return enabledProfiles, enabledUserIDs, ""
|
||||
}
|
||||
|
||||
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
|
||||
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
|
||||
if len(teamIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
teams := AgentTeamService.Find(sqls.NewCnd().
|
||||
In("id", teamIDs).
|
||||
Eq("status", enums.StatusOk))
|
||||
if len(teams) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabledTeamIDs := make([]int64, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
enabledTeamIDs = append(enabledTeamIDs, team.ID)
|
||||
}
|
||||
|
||||
schedules := AgentTeamScheduleService.Find(sqls.NewCnd().
|
||||
In("team_id", enabledTeamIDs).
|
||||
Eq("status", enums.StatusOk).
|
||||
Lte("start_at", now).
|
||||
Gt("end_at", now))
|
||||
|
||||
activeSet := make(map[int64]struct{}, len(schedules))
|
||||
for _, schedule := range schedules {
|
||||
activeSet[schedule.TeamID] = struct{}{}
|
||||
}
|
||||
|
||||
ret := make([]int64, 0, len(teamIDs))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, teamID := range teamIDs {
|
||||
if _, active := activeSet[teamID]; !active {
|
||||
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
|
||||
}
|
||||
if _, exists := seen[teamID]; exists {
|
||||
continue
|
||||
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), teamID, now)
|
||||
if err == nil && calendar.Open {
|
||||
result = append(result, teamID)
|
||||
}
|
||||
seen[teamID] = struct{}{}
|
||||
ret = append(ret, teamID)
|
||||
}
|
||||
return ret
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
|
||||
@@ -409,6 +327,10 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
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
|
||||
@@ -417,6 +339,12 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
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
|
||||
}
|
||||
@@ -443,6 +371,9 @@ func (s *conversationDispatchService) tryAssignConversation(conversationID int64
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user