refactor(server): 会话/访客/设置业务逻辑下沉 internal/service 层,handler 瘦身

- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留
- 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应
- session 875→361 行、widget 745→321 行、settings 540→73 行
- 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包
- 相关单元测试随迁 service 包,go test ./... 全部通过
This commit is contained in:
yml2213
2026-09-05 14:39:34 +08:00
parent ba3b1534f8
commit 8b9f3d710f
24 changed files with 2158 additions and 1963 deletions
+225
View File
@@ -0,0 +1,225 @@
package service
import (
"fmt"
"strings"
"sync"
"kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/ws"
)
var assignmentLocks sync.Map
func assignmentLock(tenantID uint) *sync.Mutex {
lock, _ := assignmentLocks.LoadOrStore(tenantID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
const (
AssignStrategyLeastLoad = "least_load"
AssignStrategyRoundRobin = "round_robin"
)
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
}
// 负载统计与会话更新必须串行,否则并发新会话会同时看到旧负载并突破最大并发。
lock := assignmentLock(session.TenantID)
lock.Lock()
defer lock.Unlock()
if err := model.DB.First(session, session.ID).Error; err != nil {
return nil, err
}
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
}