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:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -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
}