diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index 2240ef8..8a1ba8a 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -68,7 +68,7 @@ func seed() { // Channels for Tenant 1 channels := []model.Channel{ - {TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: ``}, + {TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: ``}, {TenantID: tenants[0].ID, Type: "wechat", Name: "微信公众号", Status: "enabled"}, {TenantID: tenants[0].ID, Type: "app", Name: "APP内嵌", Status: "disabled"}, } diff --git a/server/internal/handler/assignment.go b/server/internal/handler/assignment.go new file mode 100644 index 0000000..6448b0e --- /dev/null +++ b/server/internal/handler/assignment.go @@ -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 +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 7ad2d2a..9ff44b3 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 1a12da2..1caabca 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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: ``, + } + 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) + } +} diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index efe8338..928f0d2 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -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" { diff --git a/web/public/widget.js b/web/public/widget.js new file mode 100644 index 0000000..96fc89f --- /dev/null +++ b/web/public/widget.js @@ -0,0 +1,100 @@ +/** + * 客服云访客 Widget 嵌入脚本 + * 用法: + */ +(function () { + if (window.__KEFU_WIDGET_LOADED__) return; + window.__KEFU_WIDGET_LOADED__ = true; + + var script = document.currentScript; + if (!script) { + var scripts = document.getElementsByTagName('script'); + script = scripts[scripts.length - 1]; + } + var channelKey = (script && script.getAttribute('data-id')) || ''; + if (!channelKey) { + console.warn('[kefu-widget] missing data-id on script tag'); + return; + } + + var src = script && script.src ? script.src : ''; + var base = ''; + try { + var u = new URL(src, window.location.href); + base = u.origin; + } catch (e) { + base = window.location.origin; + } + + var open = false; + var iframe = null; + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.setAttribute('aria-label', '打开在线客服'); + btn.style.cssText = [ + 'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483000', + 'width:56px', 'height:56px', 'border:none', 'border-radius:9999px', + 'background:#2563eb', 'color:#fff', 'cursor:pointer', + 'box-shadow:0 8px 24px rgba(0,0,0,0.12)', + 'display:flex', 'align-items:center', 'justify-content:center', + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif', + ].join(';'); + btn.innerHTML = ''; + + var panel = document.createElement('div'); + panel.style.cssText = [ + 'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483001', + 'width:400px', 'height:600px', 'max-width:calc(100vw - 32px)', 'max-height:calc(100vh - 32px)', + 'border-radius:12px', 'overflow:hidden', + 'box-shadow:0 8px 24px rgba(0,0,0,0.12)', + 'display:none', 'background:#fff', + ].join(';'); + + function ensureIframe() { + if (iframe) return; + iframe = document.createElement('iframe'); + iframe.title = '在线客服'; + iframe.allow = 'clipboard-write'; + iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;'; + iframe.src = base + '/widget/embed?channel_key=' + encodeURIComponent(channelKey) + '&embedded=1'; + panel.appendChild(iframe); + } + + function setOpen(next) { + open = next; + if (open) { + ensureIframe(); + panel.style.display = 'block'; + btn.style.display = 'none'; + } else { + panel.style.display = 'none'; + btn.style.display = 'flex'; + } + } + + btn.addEventListener('click', function () { setOpen(true); }); + + window.addEventListener('message', function (event) { + if (!event || !event.data) return; + if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') { + setOpen(false); + } + }); + + function mount() { + document.body.appendChild(btn); + document.body.appendChild(panel); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount); + } else { + mount(); + } + + window.KefuWidget = { + open: function () { setOpen(true); }, + close: function () { setOpen(false); }, + channelKey: channelKey, + }; +})(); diff --git a/web/src/pages/WidgetEmbed.tsx b/web/src/pages/WidgetEmbed.tsx new file mode 100644 index 0000000..f337796 --- /dev/null +++ b/web/src/pages/WidgetEmbed.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' +import VisitorChat from '@/widgets/VisitorChat' + +/** 供 widget.js iframe 嵌入的无边框页面 */ +const WidgetEmbed = () => { + const [params] = useSearchParams() + const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params]) + const embedded = params.get('embedded') === '1' + + return ( +
右下角为可嵌入聊天组件 · 消息会写入数据库
+ ++ 右下角为聊天组件。任意站点可嵌入下方脚本(iframe 版 SDK)。 +
+
+ {``}
+
+ + 也可打开独立嵌入页: + + /widget/embed + +
+ + {sessionEnded + ? '会话已结束' + : agentsOnline + ? (agentName ? `${agentName} 为您服务` : '正在为您服务') + : '客服离线 · 可留言'} +
++ {agentsOnline + ? '您好!欢迎咨询,请问有什么可以帮您?' + : offlinePrompt} +
+{msg.content}
+ )} +{msg.content}
+ )} +客服正在输入...
+ > + )} + +- - {sessionEnded ? '会话已结束' : '正在为您服务'} -
-- 您好!欢迎咨询,请问有什么可以帮您? -
-{msg.content}
- )} -{msg.content}
- )} -客服正在输入...
- > - )} - -