实现 P0 自动分配、离线留言与可嵌入 Widget SDK

- 会话创建时按负载自动分配在线客服,无客服则进入离线模式
- 新增 /api/widget/leave-message 沉淀联系方式与留言事件
- 访客端离线留言表单;工作台展示离线留言/自动分配事件
- 提供 public/widget.js + /widget/embed 嵌入方案
This commit is contained in:
yml2213
2026-07-15 11:09:50 +08:00
parent d105840b1f
commit 9485c28abd
11 changed files with 930 additions and 282 deletions
+1 -1
View File
@@ -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"},
}
+96
View File
@@ -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
}
+1
View File
@@ -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)
}
}
+172 -11
View File
@@ -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" {