实现 P0 自动分配、离线留言与可嵌入 Widget SDK
- 会话创建时按负载自动分配在线客服,无客服则进入离线模式 - 新增 /api/widget/leave-message 沉淀联系方式与留言事件 - 访客端离线留言表单;工作台展示离线留言/自动分配事件 - 提供 public/widget.js + /widget/embed 嵌入方案
This commit is contained in:
@@ -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