支持可配置的会话自动分配策略
- 租户可设置负载最低或轮询,以及每位坐席最大进行中会话数 - 自动分配按策略过滤在线一线客服,满并发则保持排队 - 系统设置「客服分配规则」页可保存上述配置
This commit is contained in:
@@ -2,39 +2,123 @@ package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kefu-sys/server/internal/model"
|
||||
"kefu-sys/server/internal/ws"
|
||||
)
|
||||
|
||||
const offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
|
||||
const (
|
||||
assignStrategyLeastLoad = "least_load"
|
||||
assignStrategyRoundRobin = "round_robin"
|
||||
offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
|
||||
)
|
||||
|
||||
// pickLeastLoadedAgent 在在线一线客服中选择当前进行中会话最少的一位。
|
||||
func pickLeastLoadedAgent(tenantID uint) (*model.User, error) {
|
||||
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
|
||||
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online").
|
||||
Order("id asc").Find(&agents).Error; err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
best := &agents[0]
|
||||
bestCount := int64(-1)
|
||||
for i := range agents {
|
||||
var count int64
|
||||
if err := model.DB.Model(&model.Session{}).
|
||||
Where("tenant_id = ? AND agent_id = ? AND status = ?", tenantID, agents[i].ID, "active").
|
||||
Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bestCount < 0 || count < bestCount {
|
||||
bestCount = count
|
||||
best = &agents[i]
|
||||
}
|
||||
candidates, counts, err := filterAssignable(tenantID, agents, maxActive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return best, nil
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
strategy = normalizeAssignStrategy(strategy)
|
||||
if strategy == assignStrategyRoundRobin {
|
||||
return pickRoundRobinAgent(candidates, rrLast), nil
|
||||
}
|
||||
return pickLeastLoadedAgent(candidates, counts), nil
|
||||
}
|
||||
|
||||
// countOnlineAgents 统计租户当前可接待的在线客服数。
|
||||
@@ -46,7 +130,7 @@ func countOnlineAgents(tenantID uint) (int64, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
// tryAutoAssign 将会话自动分配给负载最低的在线客服;无可分配客服时返回 nil。
|
||||
// tryAutoAssign 按租户分配策略自动分配 waiting 会话;无可分配客服时返回 nil。
|
||||
func tryAutoAssign(session *model.Session) (*model.User, error) {
|
||||
if session == nil || session.ID == 0 {
|
||||
return nil, nil
|
||||
@@ -55,7 +139,18 @@ func tryAutoAssign(session *model.Session) (*model.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
agent, err := pickLeastLoadedAgent(session.TenantID)
|
||||
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
|
||||
}
|
||||
@@ -80,7 +175,24 @@ func tryAutoAssign(session *model.Session) (*model.User, error) {
|
||||
|
||||
session.AgentID = &agent.ID
|
||||
session.Status = "active"
|
||||
detail := fmt.Sprintf("系统自动分配给 %s", agent.Nickname)
|
||||
|
||||
// 推进轮询游标(仅 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,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"kefu-sys/server/internal/model"
|
||||
)
|
||||
|
||||
func TestPickLeastLoadedPrefersFewerSessions(t *testing.T) {
|
||||
a := model.User{ID: 1}
|
||||
b := model.User{ID: 2}
|
||||
c := model.User{ID: 3}
|
||||
candidates := []model.User{a, b, c}
|
||||
counts := map[uint]int64{1: 3, 2: 1, 3: 1}
|
||||
got := pickLeastLoadedAgent(candidates, counts)
|
||||
if got == nil || got.ID != 2 {
|
||||
t.Fatalf("期望 id=2(平局取更小 id),got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRoundRobinAdvances(t *testing.T) {
|
||||
candidates := []model.User{{ID: 10}, {ID: 20}, {ID: 30}}
|
||||
last := uint(10)
|
||||
got := pickRoundRobinAgent(candidates, &last)
|
||||
if got == nil || got.ID != 20 {
|
||||
t.Fatalf("期望下一位 20,got=%v", got)
|
||||
}
|
||||
last = 30
|
||||
got = pickRoundRobinAgent(candidates, &last)
|
||||
if got == nil || got.ID != 10 {
|
||||
t.Fatalf("期望绕回 10,got=%v", got)
|
||||
}
|
||||
got = pickRoundRobinAgent(candidates, nil)
|
||||
if got == nil || got.ID != 10 {
|
||||
t.Fatalf("无游标时期望第一位 10,got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAssignStrategy(t *testing.T) {
|
||||
if normalizeAssignStrategy("round_robin") != assignStrategyRoundRobin {
|
||||
t.Fatal("round_robin")
|
||||
}
|
||||
if normalizeAssignStrategy("") != assignStrategyLeastLoad {
|
||||
t.Fatal("default least_load")
|
||||
}
|
||||
if normalizeAssignStrategy("unknown") != assignStrategyLeastLoad {
|
||||
t.Fatal("unknown -> least_load")
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) {
|
||||
NotifyNewSession: true,
|
||||
NotifyOfflineLeave: true,
|
||||
NotifyDailyReport: false,
|
||||
AssignStrategy: assignStrategyLeastLoad,
|
||||
MaxActivePerAgent: 0,
|
||||
}
|
||||
if err := model.DB.Create(&setting).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -135,18 +137,25 @@ func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool {
|
||||
}
|
||||
|
||||
func settingsResponse(setting *model.TenantSetting) gin.H {
|
||||
strategy := normalizeAssignStrategy(setting.AssignStrategy)
|
||||
maxActive := setting.MaxActivePerAgent
|
||||
if maxActive < 0 {
|
||||
maxActive = 0
|
||||
}
|
||||
return gin.H{
|
||||
"tenant_id": setting.TenantID,
|
||||
"display_name": setting.DisplayName,
|
||||
"agent_nickname": setting.AgentNickname,
|
||||
"timezone": setting.Timezone,
|
||||
"welcome_message": setting.WelcomeMessage,
|
||||
"offline_prompt": setting.OfflinePrompt,
|
||||
"work_hours": parseWorkHours(setting.WorkHoursJSON),
|
||||
"worktime_prompt": setting.WorktimePrompt,
|
||||
"notify_new_session": setting.NotifyNewSession,
|
||||
"notify_offline_leave": setting.NotifyOfflineLeave,
|
||||
"notify_daily_report": setting.NotifyDailyReport,
|
||||
"tenant_id": setting.TenantID,
|
||||
"display_name": setting.DisplayName,
|
||||
"agent_nickname": setting.AgentNickname,
|
||||
"timezone": setting.Timezone,
|
||||
"welcome_message": setting.WelcomeMessage,
|
||||
"offline_prompt": setting.OfflinePrompt,
|
||||
"work_hours": parseWorkHours(setting.WorkHoursJSON),
|
||||
"worktime_prompt": setting.WorktimePrompt,
|
||||
"notify_new_session": setting.NotifyNewSession,
|
||||
"notify_offline_leave": setting.NotifyOfflineLeave,
|
||||
"notify_daily_report": setting.NotifyDailyReport,
|
||||
"assign_strategy": strategy,
|
||||
"max_active_per_agent": maxActive,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +184,8 @@ type updateSettingsReq struct {
|
||||
NotifyNewSession *bool `json:"notify_new_session"`
|
||||
NotifyOfflineLeave *bool `json:"notify_offline_leave"`
|
||||
NotifyDailyReport *bool `json:"notify_daily_report"`
|
||||
AssignStrategy *string `json:"assign_strategy"`
|
||||
MaxActivePerAgent *int `json:"max_active_per_agent"`
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
@@ -284,6 +295,21 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
if req.NotifyDailyReport != nil {
|
||||
updates["notify_daily_report"] = *req.NotifyDailyReport
|
||||
}
|
||||
if req.AssignStrategy != nil {
|
||||
raw := strings.TrimSpace(*req.AssignStrategy)
|
||||
if raw != assignStrategyLeastLoad && raw != assignStrategyRoundRobin {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "分配策略无效,可选 least_load / round_robin"})
|
||||
return
|
||||
}
|
||||
updates["assign_strategy"] = raw
|
||||
}
|
||||
if req.MaxActivePerAgent != nil {
|
||||
if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "最大并发需为 0–200(0 表示不限制)"})
|
||||
return
|
||||
}
|
||||
updates["max_active_per_agent"] = *req.MaxActivePerAgent
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user