实现 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
@@ -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)
}
}