实现 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
+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" {