实现 P0 自动分配、离线留言与可嵌入 Widget SDK
- 会话创建时按负载自动分配在线客服,无客服则进入离线模式 - 新增 /api/widget/leave-message 沉淀联系方式与留言事件 - 访客端离线留言表单;工作台展示离线留言/自动分配事件 - 提供 public/widget.js + /widget/embed 嵌入方案
This commit is contained in:
@@ -68,7 +68,7 @@ func seed() {
|
||||
|
||||
// Channels for Tenant 1
|
||||
channels := []model.Channel{
|
||||
{TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: `<script src="https://cs.example.com/widget.js" data-id="WK_8a3f2e"></script>`},
|
||||
{TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: `<script src="/widget.js" data-id="WK_8a3f2e"></script>`},
|
||||
{TenantID: tenants[0].ID, Type: "wechat", Name: "微信公众号", Status: "enabled"},
|
||||
{TenantID: tenants[0].ID, Type: "app", Name: "APP内嵌", Status: "disabled"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kefu-sys/server/internal/model"
|
||||
"kefu-sys/server/internal/ws"
|
||||
)
|
||||
|
||||
const offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
|
||||
|
||||
// pickLeastLoadedAgent 在在线一线客服中选择当前进行中会话最少的一位。
|
||||
func pickLeastLoadedAgent(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 {
|
||||
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]
|
||||
}
|
||||
}
|
||||
return best, 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 将会话自动分配给负载最低的在线客服;无可分配客服时返回 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
|
||||
}
|
||||
|
||||
agent, err := pickLeastLoadedAgent(session.TenantID)
|
||||
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"
|
||||
detail := fmt.Sprintf("系统自动分配给 %s", agent.Nickname)
|
||||
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
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
widgetApi.POST("/init", widget.Init)
|
||||
widgetApi.GET("/init", widget.Init)
|
||||
widgetApi.POST("/message", widget.SendMessage)
|
||||
widgetApi.POST("/leave-message", widget.LeaveMessage)
|
||||
widgetApi.GET("/messages", widget.GetMessages)
|
||||
widgetApi.GET("/ws", widget.Connect)
|
||||
widgetApi.POST("/rating", widget.SubmitRating)
|
||||
|
||||
@@ -438,3 +438,105 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
||||
t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "自动分配租户", "normal")
|
||||
channel := model.Channel{
|
||||
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
|
||||
ScriptCode: `<script data-id="WK_auto_001"></script>`,
|
||||
}
|
||||
if err := model.DB.Create(&channel).Error; err != nil {
|
||||
t.Fatalf("创建渠道失败: %v", err)
|
||||
}
|
||||
|
||||
// 无在线客服 → 离线模式
|
||||
initRecorder := httptest.NewRecorder()
|
||||
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"离线访客"}`))
|
||||
initReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(initRecorder, initReq)
|
||||
if initRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("离线初始化失败: %s", initRecorder.Body.String())
|
||||
}
|
||||
var offlineInit struct {
|
||||
Data struct {
|
||||
SessionID uint `json:"session_id"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
AgentsOnline bool `json:"agents_online"`
|
||||
OfflinePrompt string `json:"offline_prompt"`
|
||||
SessionStatus string `json:"session_status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(initRecorder.Body.Bytes(), &offlineInit); err != nil {
|
||||
t.Fatalf("解析离线初始化失败: %v", err)
|
||||
}
|
||||
if offlineInit.Data.AgentsOnline || offlineInit.Data.OfflinePrompt == "" || offlineInit.Data.SessionStatus != "waiting" {
|
||||
t.Fatalf("期望离线等待会话: %+v", offlineInit.Data)
|
||||
}
|
||||
|
||||
leaveRecorder := httptest.NewRecorder()
|
||||
leaveBody := fmt.Sprintf(`{"session_id":%d,"content":"请回电处理订单问题","name":"张留言","phone":"13800138000","email":"leave@example.com"}`, offlineInit.Data.SessionID)
|
||||
leaveReq := httptest.NewRequest(http.MethodPost, "/api/widget/leave-message", bytes.NewBufferString(leaveBody))
|
||||
leaveReq.Header.Set("Content-Type", "application/json")
|
||||
leaveReq.Header.Set("X-Visitor-Token", offlineInit.Data.VisitorToken)
|
||||
router.ServeHTTP(leaveRecorder, leaveReq)
|
||||
if leaveRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("离线留言失败: %s", leaveRecorder.Body.String())
|
||||
}
|
||||
var session model.Session
|
||||
if err := model.DB.First(&session, offlineInit.Data.SessionID).Error; err != nil {
|
||||
t.Fatalf("查询会话失败: %v", err)
|
||||
}
|
||||
var customer model.Customer
|
||||
if err := model.DB.First(&customer, session.CustomerID).Error; err != nil {
|
||||
t.Fatalf("查询客户失败: %v", err)
|
||||
}
|
||||
if customer.Phone != "13800138000" || customer.Email != "leave@example.com" || customer.Name != "张留言" {
|
||||
t.Fatalf("联系方式未沉淀: %+v", customer)
|
||||
}
|
||||
var msgCount int64
|
||||
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Count(&msgCount)
|
||||
if msgCount != 1 {
|
||||
t.Fatalf("留言消息数 = %d", msgCount)
|
||||
}
|
||||
var event model.SessionEvent
|
||||
if err := model.DB.Where("session_id = ? AND action = ?", session.ID, "offline_leave").First(&event).Error; err != nil {
|
||||
t.Fatalf("未记录离线留言事件: %v", err)
|
||||
}
|
||||
|
||||
// 有在线客服 → 自动分配
|
||||
agent := createUser(t, tenant.ID, "auto-agent-1", "agent")
|
||||
init2 := httptest.NewRecorder()
|
||||
init2Req := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"在线访客"}`))
|
||||
init2Req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(init2, init2Req)
|
||||
if init2.Code != http.StatusOK {
|
||||
t.Fatalf("在线初始化失败: %s", init2.Body.String())
|
||||
}
|
||||
var onlineInit struct {
|
||||
Data struct {
|
||||
SessionID uint `json:"session_id"`
|
||||
AgentsOnline bool `json:"agents_online"`
|
||||
SessionStatus string `json:"session_status"`
|
||||
AgentID uint `json:"agent_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(init2.Body.Bytes(), &onlineInit); err != nil {
|
||||
t.Fatalf("解析在线初始化失败: %v", err)
|
||||
}
|
||||
if !onlineInit.Data.AgentsOnline || onlineInit.Data.SessionStatus != "active" || onlineInit.Data.AgentID != agent.ID {
|
||||
t.Fatalf("期望自动分配给在线客服: %+v agent=%d", onlineInit.Data, agent.ID)
|
||||
}
|
||||
var assigned model.Session
|
||||
if err := model.DB.First(&assigned, onlineInit.Data.SessionID).Error; err != nil {
|
||||
t.Fatalf("查询已分配会话失败: %v", err)
|
||||
}
|
||||
if assigned.AgentID == nil || *assigned.AgentID != agent.ID || assigned.Status != "active" {
|
||||
t.Fatalf("会话分配状态不正确: %+v", assigned)
|
||||
}
|
||||
var assignEvent model.SessionEvent
|
||||
if err := model.DB.Where("session_id = ? AND action = ?", assigned.ID, "auto_assign").First(&assignEvent).Error; err != nil {
|
||||
t.Fatalf("未记录自动分配事件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,18 @@ type WidgetRatingReq struct {
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
}
|
||||
|
||||
type WidgetLeaveReq struct {
|
||||
SessionID uint `json:"session_id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
}
|
||||
|
||||
var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`)
|
||||
var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
var req WidgetInitReq
|
||||
@@ -68,7 +79,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
name := req.VisitorName
|
||||
name := strings.TrimSpace(req.VisitorName)
|
||||
if name == "" {
|
||||
name = "访客"
|
||||
}
|
||||
@@ -107,20 +118,45 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
}
|
||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID).
|
||||
Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()})
|
||||
|
||||
onlineCount, err := countOnlineAgents(channel.TenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"})
|
||||
return
|
||||
}
|
||||
|
||||
var assignedAgent *model.User
|
||||
if onlineCount > 0 {
|
||||
assignedAgent, err = tryAutoAssign(&session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"session_id": session.ID,
|
||||
"customer_id": customer.ID,
|
||||
"channel_id": channel.ID,
|
||||
"tenant_id": channel.TenantID,
|
||||
"visitor_token": visitorToken,
|
||||
},
|
||||
})
|
||||
agentsOnline := onlineCount > 0
|
||||
resp := gin.H{
|
||||
"session_id": session.ID,
|
||||
"customer_id": customer.ID,
|
||||
"channel_id": channel.ID,
|
||||
"tenant_id": channel.TenantID,
|
||||
"visitor_token": visitorToken,
|
||||
"agents_online": agentsOnline,
|
||||
"session_status": session.Status,
|
||||
}
|
||||
if !agentsOnline {
|
||||
resp["offline_prompt"] = offlineLeavePrompt
|
||||
}
|
||||
if assignedAgent != nil {
|
||||
resp["agent_id"] = assignedAgent.ID
|
||||
resp["agent_name"] = assignedAgent.Nickname
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||
}
|
||||
|
||||
func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
|
||||
@@ -182,6 +218,14 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
|
||||
|
||||
// 仍在排队时尝试自动分配(客服刚上线的场景)
|
||||
if session.Status == "waiting" && session.AgentID == nil {
|
||||
if _, err := tryAutoAssign(session); err == nil {
|
||||
_ = model.DB.First(session, session.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
||||
if session.AgentID == nil {
|
||||
// 等待会话的消息要通知全部客服,便于任一在线客服及时领取。
|
||||
@@ -194,6 +238,123 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
||||
}
|
||||
|
||||
// LeaveMessage 无客服在线时提交留言并沉淀联系方式。
|
||||
func (h *WidgetHandler) LeaveMessage(c *gin.Context) {
|
||||
var req WidgetLeaveReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
content, err := validateMessageContent("text", req.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if phone == "" && email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请至少填写手机号或邮箱,便于我们回复"})
|
||||
return
|
||||
}
|
||||
if phone != "" && !phonePattern.MatchString(phone) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "手机号格式不正确"})
|
||||
return
|
||||
}
|
||||
if email != "" && !emailPattern.MatchString(email) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
if name != "" && (len([]rune(name)) < 2 || len([]rune(name)) > 50) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "姓名需为 2 至 50 个字符"})
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"})
|
||||
return
|
||||
}
|
||||
|
||||
// 若此刻已有客服在线,直接走自动分配,留言仍作为普通消息入库
|
||||
onlineCount, _ := countOnlineAgents(session.TenantID)
|
||||
if onlineCount > 0 && session.Status == "waiting" {
|
||||
if _, err := tryAutoAssign(session); err == nil {
|
||||
_ = model.DB.First(session, session.ID)
|
||||
}
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{"last_contact_at": time.Now()}
|
||||
if name != "" {
|
||||
updates["name"] = name
|
||||
}
|
||||
if phone != "" {
|
||||
updates["phone"] = phone
|
||||
}
|
||||
if email != "" {
|
||||
updates["email"] = email
|
||||
}
|
||||
if err := model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).
|
||||
Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新联系方式失败"})
|
||||
return
|
||||
}
|
||||
|
||||
custID := session.CustomerID
|
||||
msg := model.Message{
|
||||
SessionID: session.ID,
|
||||
SenderType: "visitor",
|
||||
SenderID: &custID,
|
||||
Content: content,
|
||||
Type: "text",
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
if err := model.CreateMessage(&msg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "留言失败"})
|
||||
return
|
||||
}
|
||||
|
||||
contactBits := make([]string, 0, 3)
|
||||
if name != "" {
|
||||
contactBits = append(contactBits, "姓名:"+name)
|
||||
}
|
||||
if phone != "" {
|
||||
contactBits = append(contactBits, "手机:"+phone)
|
||||
}
|
||||
if email != "" {
|
||||
contactBits = append(contactBits, "邮箱:"+email)
|
||||
}
|
||||
model.DB.Create(&model.SessionEvent{
|
||||
SessionID: session.ID,
|
||||
OperatorID: 0,
|
||||
Action: "offline_leave",
|
||||
Detail: "离线留言 · " + strings.Join(contactBits, " · "),
|
||||
})
|
||||
|
||||
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
||||
if session.AgentID == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
} else {
|
||||
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
||||
}
|
||||
}
|
||||
if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"message": msg,
|
||||
"session_status": session.Status,
|
||||
"agents_online": onlineCount > 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func visitorTokenFromWebSocket(c *gin.Context) string {
|
||||
protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",")
|
||||
if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-visitor-v1" {
|
||||
|
||||
Reference in New Issue
Block a user