diff --git a/server/internal/handler/assignment.go b/server/internal/handler/assignment.go index 6448b0e..4f0057b 100644 --- a/server/internal/handler/assignment.go +++ b/server/internal/handler/assignment.go @@ -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, diff --git a/server/internal/handler/assignment_test.go b/server/internal/handler/assignment_test.go new file mode 100644 index 0000000..f45af85 --- /dev/null +++ b/server/internal/handler/assignment_test.go @@ -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") + } +} diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index ded8ead..01564c1 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -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 diff --git a/server/internal/model/models.go b/server/internal/model/models.go index eb35866..83952a5 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -156,7 +156,7 @@ type Announcement struct { UpdatedAt time.Time `json:"updated_at"` } -// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关等)。 +// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关、分配策略等)。 type TenantSetting struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"` @@ -170,6 +170,12 @@ type TenantSetting struct { NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"` NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"` NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // AssignStrategy 自动分配策略:least_load(默认)| round_robin + AssignStrategy string `gorm:"size:30;default:least_load" json:"assign_strategy"` + // MaxActivePerAgent 每位坐席最大进行中会话数;0 表示不限制 + MaxActivePerAgent int `gorm:"default:0" json:"max_active_per_agent"` + // RRLastAgentID 轮询策略的上次分配坐席游标 + RRLastAgentID *uint `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index 0a07b0d..af2cab7 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -55,7 +55,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] { key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: }, { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, - { key: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: }, + { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: }, { key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: }, { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, @@ -71,6 +71,7 @@ const Settings = () => { const [autoReplyForm] = Form.useForm() const [workForm] = Form.useForm() const [notifyForm] = Form.useForm() + const [assignForm] = Form.useForm() const [staffForm] = Form.useForm() const [channels, setChannels] = useState([]) const [settings, setSettings] = useState(null) @@ -142,6 +143,10 @@ const Settings = () => { notify_offline_leave: data.notify_offline_leave, notify_daily_report: data.notify_daily_report, }) + assignForm.setFieldsValue({ + assign_strategy: data.assign_strategy || 'least_load', + max_active_per_agent: data.max_active_per_agent ?? 0, + }) } catch (e) { message.error(e instanceof Error ? e.message : '加载设置失败') } finally { @@ -151,7 +156,7 @@ const Settings = () => { useEffect(() => { if (activeTab === 'channels') loadChannels() - if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings() + if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings() if (activeTab === 'staff' && canViewStaff) loadStaff() }, [activeTab]) @@ -504,29 +509,89 @@ const Settings = () => { )} {activeTab === 'assignment' && ( -
-
-
-
- -
-
-
当前生效:负载最低优先 + 工作时间
-
- 工作时间内,新会话自动分配给进行中会话最少的在线客服;非工作时间或无客服在线时,访客可提交离线留言。 +
+ {loadingSettings ? ( +
+ ) : ( + <> +
+
+
+ +
+
+
自动分配规则
+
+ 工作时间内,访客进线会按下列策略分配给在线一线客服; + 非工作时间或无可接坐席时进入排队/留言。人工领取与转接不受此策略限制。 +
+
-
-
-
-
-
-
轮询分配
-
后续版本可切换策略
+ +
+
{ + await savePartial({ + assign_strategy: values.assign_strategy, + max_active_per_agent: Number(values.max_active_per_agent) || 0, + }, '分配规则已保存') + }} + > + + + + {isAdmin ? ( + + ) : ( +

仅租户管理员可修改分配规则

+ )} +
- -
-
+ +
+
说明
+

· 候选范围:角色为客服、状态为「在线」的账号(忙碌/离线不参与自动分配)。

+

· 技能组 / 渠道专属坐席将在后续版本支持。

+
+ + )}
)} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index f69d4de..9ecd3eb 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -318,6 +318,8 @@ export const deleteStaff = (id: number) => del(`/staff/${id}`) // Tenant settings export type WorkHours = Record +export type AssignStrategy = 'least_load' | 'round_robin' + export interface TenantSettings { tenant_id: number display_name: string @@ -330,6 +332,10 @@ export interface TenantSettings { notify_new_session: boolean notify_offline_leave: boolean notify_daily_report: boolean + /** least_load 负载最低 | round_robin 轮询 */ + assign_strategy?: AssignStrategy + /** 每位坐席最大进行中会话数,0=不限制 */ + max_active_per_agent?: number } export const getTenantSettings = () => get('/settings') export const updateTenantSettings = (data: Partial & { work_hours?: WorkHours }) =>