Files
kefu_cloud/server/internal/handler/assignment.go
T
yml2213 216ab2c85a 支持可配置的会话自动分配策略
- 租户可设置负载最低或轮询,以及每位坐席最大进行中会话数
- 自动分配按策略过滤在线一线客服,满并发则保持排队
- 系统设置「客服分配规则」页可保存上述配置
2026-07-15 15:07:41 +08:00

209 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"fmt"
"strings"
"kefu-sys/server/internal/model"
"kefu-sys/server/internal/ws"
)
const (
assignStrategyLeastLoad = "least_load"
assignStrategyRoundRobin = "round_robin"
offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
)
func normalizeAssignStrategy(raw string) string {
switch strings.TrimSpace(raw) {
case assignStrategyRoundRobin:
return assignStrategyRoundRobin
default:
return assignStrategyLeastLoad
}
}
// listOnlineAgents 租户内可自动接待的一线客服(online + role=agent)。
func listOnlineAgents(tenantID uint) ([]model.User, error) {
var agents []model.User
err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
Order("id asc").Find(&agents).Error
return agents, err
}
func agentActiveSessionCount(tenantID, agentID uint) (int64, error) {
var count int64
err := model.DB.Model(&model.Session{}).
Where("tenant_id = ? AND agent_id = ? AND status = ?", tenantID, agentID, "active").
Count(&count).Error
return count, err
}
// filterAssignable 按最大并发过滤;maxActive<=0 表示不限制。
func filterAssignable(tenantID uint, agents []model.User, maxActive int) ([]model.User, map[uint]int64, error) {
counts := make(map[uint]int64, len(agents))
out := make([]model.User, 0, len(agents))
for i := range agents {
n, err := agentActiveSessionCount(tenantID, agents[i].ID)
if err != nil {
return nil, nil, err
}
counts[agents[i].ID] = n
if maxActive > 0 && int(n) >= maxActive {
continue
}
out = append(out, agents[i])
}
return out, counts, nil
}
// pickLeastLoadedAgent 在候选中选 active 会话最少者;平局取 id 更小。
func pickLeastLoadedAgent(candidates []model.User, counts map[uint]int64) *model.User {
if len(candidates) == 0 {
return nil
}
best := &candidates[0]
bestCount := counts[best.ID]
for i := 1; i < len(candidates); i++ {
c := counts[candidates[i].ID]
if c < bestCount || (c == bestCount && candidates[i].ID < best.ID) {
bestCount = c
best = &candidates[i]
}
}
return best
}
// pickRoundRobinAgent 在按 id 排序的候选中,从 lastAgentID 之后轮转下一位。
func pickRoundRobinAgent(candidates []model.User, lastAgentID *uint) *model.User {
if len(candidates) == 0 {
return nil
}
if lastAgentID == nil || *lastAgentID == 0 {
return &candidates[0]
}
// 找到 last 在全序中的位置,取其后第一个仍在候选中的
start := 0
for i, a := range candidates {
if a.ID > *lastAgentID {
start = i
return &candidates[start]
}
if a.ID == *lastAgentID {
start = (i + 1) % len(candidates)
return &candidates[start]
}
}
// last 已不在列表(离线/满载),从头开始
return &candidates[0]
}
// pickAgent 按租户策略选择可分配坐席。
func pickAgent(tenantID uint, strategy string, maxActive int, rrLast *uint) (*model.User, error) {
agents, err := listOnlineAgents(tenantID)
if err != nil {
return nil, err
}
if len(agents) == 0 {
return nil, nil
}
candidates, counts, err := filterAssignable(tenantID, agents, maxActive)
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return nil, nil
}
strategy = normalizeAssignStrategy(strategy)
if strategy == assignStrategyRoundRobin {
return pickRoundRobinAgent(candidates, rrLast), nil
}
return pickLeastLoadedAgent(candidates, counts), nil
}
// countOnlineAgents 统计租户当前可接待的在线客服数。
func countOnlineAgents(tenantID uint) (int64, error) {
var count int64
err := model.DB.Model(&model.User{}).
Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
Count(&count).Error
return count, err
}
// tryAutoAssign 按租户分配策略自动分配 waiting 会话;无可分配客服时返回 nil。
func tryAutoAssign(session *model.Session) (*model.User, error) {
if session == nil || session.ID == 0 {
return nil, nil
}
if session.Status != "waiting" || session.AgentID != nil {
return nil, nil
}
setting, err := getOrCreateTenantSettings(session.TenantID)
if err != nil {
// 设置失败时回退默认 least_load、不限制并发
setting = &model.TenantSetting{AssignStrategy: assignStrategyLeastLoad}
}
strategy := normalizeAssignStrategy(setting.AssignStrategy)
maxActive := setting.MaxActivePerAgent
if maxActive < 0 {
maxActive = 0
}
agent, err := pickAgent(session.TenantID, strategy, maxActive, setting.RRLastAgentID)
if err != nil || agent == nil {
return nil, err
}
result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ? AND agent_id IS NULL", session.ID, session.TenantID, "waiting").
Updates(map[string]interface{}{
"agent_id": agent.ID,
"status": "active",
"last_read_seq": 0,
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
// 并发下可能已被他人领取
if err := model.DB.First(session, session.ID).Error; err != nil {
return nil, err
}
return nil, nil
}
session.AgentID = &agent.ID
session.Status = "active"
// 推进轮询游标(仅 round_robin
if strategy == assignStrategyRoundRobin && setting.ID > 0 {
aid := agent.ID
_ = model.DB.Model(&model.TenantSetting{}).
Where("id = ?", setting.ID).
Update("rr_last_agent_id", aid).Error
setting.RRLastAgentID = &aid
}
strategyLabel := "负载最低"
if strategy == assignStrategyRoundRobin {
strategyLabel = "轮询"
}
detail := fmt.Sprintf("系统自动分配给 %s%s", agent.Nickname, strategyLabel)
if agent.Nickname == "" {
detail = fmt.Sprintf("系统自动分配给 %s%s", agent.Username, strategyLabel)
}
model.DB.Create(&model.SessionEvent{
SessionID: session.ID,
OperatorID: 0,
Action: "auto_assign",
Detail: detail,
})
if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
ws.DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload)
}
return agent, nil
}