完成客服工作台闭环

This commit is contained in:
yml2213
2026-07-15 10:49:52 +08:00
parent 8cff2a5824
commit 1d31c9eba3
12 changed files with 727 additions and 235 deletions
@@ -0,0 +1,45 @@
package handler
import (
"encoding/base64"
"errors"
"strings"
"unicode/utf8"
)
const (
maxTextMessageLength = 2000
maxImageMessageSize = 5 * 1024 * 1024
)
func validateMessageContent(messageType, content string) (string, error) {
switch messageType {
case "text":
content = strings.TrimSpace(content)
if content == "" {
return "", errors.New("消息内容不能为空")
}
if utf8.RuneCountInString(content) > maxTextMessageLength {
return "", errors.New("单条文本消息不能超过 2000 字")
}
return content, nil
case "image":
parts := strings.SplitN(content, ",", 2)
if len(parts) != 2 {
return "", errors.New("图片格式无效")
}
if parts[0] != "data:image/jpeg;base64" && parts[0] != "data:image/png;base64" && parts[0] != "data:image/gif;base64" {
return "", errors.New("仅支持 jpg、png、gif 图片")
}
bytes, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil || len(bytes) == 0 {
return "", errors.New("图片内容无效")
}
if len(bytes) > maxImageMessageSize {
return "", errors.New("图片不能超过 5 MB")
}
return content, nil
default:
return "", errors.New("不支持的消息类型")
}
}
@@ -0,0 +1,22 @@
package handler
import (
"strings"
"testing"
)
func TestValidateMessageContent(t *testing.T) {
validImage := "data:image/png;base64,aGVsbG8="
if content, err := validateMessageContent("image", validImage); err != nil || content != validImage {
t.Fatalf("合法图片校验失败: content=%q err=%v", content, err)
}
if _, err := validateMessageContent("image", "data:image/webp;base64,aGVsbG8="); err == nil {
t.Fatal("不支持的图片格式未被拦截")
}
if _, err := validateMessageContent("text", " "); err == nil {
t.Fatal("空白文本未被拦截")
}
if _, err := validateMessageContent("text", strings.Repeat("字", maxTextMessageLength+1)); err == nil {
t.Fatal("超长文本未被拦截")
}
}
+3
View File
@@ -37,6 +37,7 @@ func SetupRoutes(r *gin.Engine) {
authRequired.GET("/ws", ws.Connect)
// 会话管理
authRequired.GET("/agents/available", session.ListAvailableAgents)
sessions := authRequired.Group("/sessions")
sessions.GET("", session.List)
sessions.GET("/:id", session.Get)
@@ -46,6 +47,8 @@ func SetupRoutes(r *gin.Engine) {
sessions.POST("/:id/end", session.End)
sessions.PUT("/:id/priority", session.UpdatePriority)
sessions.POST("/:id/messages", session.SendMessage)
sessions.POST("/:id/read", session.MarkRead)
sessions.POST("/:id/notes", session.AddNote)
// 客户管理
customers := authRequired.Group("/customers")
@@ -336,3 +336,105 @@ func TestKnowledgeEntryRespectsPlanCapacity(t *testing.T) {
t.Fatalf("超出知识库容量状态码 = %d,期望 %d,响应 = %s", recorder.Code, http.StatusConflict, recorder.Body.String())
}
}
func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "工作台租户", "normal")
agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent")
agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "agent")
customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"}
if err := model.DB.Create(&customer).Error; err != nil {
t.Fatalf("创建工作台客户失败: %v", err)
}
session := model.Session{TenantID: tenant.ID, CustomerID: customer.ID, Status: "waiting", Priority: "normal"}
if err := model.DB.Create(&session).Error; err != nil {
t.Fatalf("创建等待会话失败: %v", err)
}
visitorMessage := model.Message{SessionID: session.ID, SenderType: "visitor", Content: "需要咨询", Type: "text", Seq: 1, SentAt: time.Now()}
if err := model.DB.Create(&visitorMessage).Error; err != nil {
t.Fatalf("创建访客消息失败: %v", err)
}
availableRecorder := httptest.NewRecorder()
router.ServeHTTP(availableRecorder, bearerRequest(t, http.MethodGet, "/api/agents/available", nil, agentOne))
if availableRecorder.Code != http.StatusOK {
t.Fatalf("查询在线客服失败: %d %s", availableRecorder.Code, availableRecorder.Body.String())
}
claimRecorder := httptest.NewRecorder()
router.ServeHTTP(claimRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/assign", session.ID), []byte(`{}`), agentOne))
if claimRecorder.Code != http.StatusOK {
t.Fatalf("领取会话失败: %d %s", claimRecorder.Code, claimRecorder.Body.String())
}
unreadRecorder := httptest.NewRecorder()
router.ServeHTTP(unreadRecorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, agentOne))
var unreadResponse struct {
List []struct {
ID uint `json:"id"`
UnreadCount int `json:"unread_count"`
} `json:"list"`
}
if err := json.Unmarshal(unreadRecorder.Body.Bytes(), &unreadResponse); err != nil {
t.Fatalf("解析未读列表失败: %v", err)
}
if len(unreadResponse.List) != 1 || unreadResponse.List[0].UnreadCount != 1 {
t.Fatalf("领取后的未读数不正确: %s", unreadRecorder.Body.String())
}
readRecorder := httptest.NewRecorder()
router.ServeHTTP(readRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/read", session.ID), []byte(`{}`), agentOne))
if readRecorder.Code != http.StatusOK {
t.Fatalf("标记已读失败: %d %s", readRecorder.Code, readRecorder.Body.String())
}
readListRecorder := httptest.NewRecorder()
router.ServeHTTP(readListRecorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, agentOne))
var readListResponse struct {
List []struct {
UnreadCount int `json:"unread_count"`
} `json:"list"`
}
if err := json.Unmarshal(readListRecorder.Body.Bytes(), &readListResponse); err != nil || len(readListResponse.List) != 1 || readListResponse.List[0].UnreadCount != 0 {
t.Fatalf("标记已读后的未读数不正确: %s", readListRecorder.Body.String())
}
noteRecorder := httptest.NewRecorder()
router.ServeHTTP(noteRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/notes", session.ID), []byte(`{"content":"已核对客户需求"}`), agentOne))
if noteRecorder.Code != http.StatusOK {
t.Fatalf("添加内部备注失败: %d %s", noteRecorder.Code, noteRecorder.Body.String())
}
transferRecorder := httptest.NewRecorder()
transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID))
router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne))
if transferRecorder.Code != http.StatusOK {
t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String())
}
oldAgentMessageRecorder := httptest.NewRecorder()
router.ServeHTTP(oldAgentMessageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), []byte(`{"content":"越权回复"}`), agentOne))
if oldAgentMessageRecorder.Code != http.StatusForbidden {
t.Fatalf("转接后原客服仍可回复: %d %s", oldAgentMessageRecorder.Code, oldAgentMessageRecorder.Body.String())
}
imageRecorder := httptest.NewRecorder()
imageBody := []byte(`{"content":"data:image/png;base64,aGVsbG8=","type":"image"}`)
router.ServeHTTP(imageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), imageBody, agentTwo))
if imageRecorder.Code != http.StatusOK {
t.Fatalf("发送图片消息失败: %d %s", imageRecorder.Code, imageRecorder.Body.String())
}
invalidEndRecorder := httptest.NewRecorder()
router.ServeHTTP(invalidEndRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/end?reason=bad_reason", session.ID), []byte(`{}`), agentTwo))
if invalidEndRecorder.Code != http.StatusBadRequest {
t.Fatalf("无效结束原因未被拦截: %d %s", invalidEndRecorder.Code, invalidEndRecorder.Body.String())
}
var savedSession model.Session
if err := model.DB.First(&savedSession, session.ID).Error; err != nil {
t.Fatalf("读取工作台会话失败: %v", err)
}
if savedSession.AgentID == nil || *savedSession.AgentID != agentTwo.ID || savedSession.LastReadSeq != 2 {
t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession)
}
}
+125 -6
View File
@@ -2,7 +2,9 @@ package handler
import (
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -20,6 +22,15 @@ type SendMessageReq struct {
Type string `json:"type"`
}
type SessionListItem struct {
model.Session
UnreadCount int `json:"unread_count"`
}
type CreateNoteReq struct {
Content string `json:"content" binding:"required"`
}
func (h *SessionHandler) SendMessage(c *gin.Context) {
userID := middleware.GetUserID(c)
id := c.Param("id")
@@ -32,6 +43,11 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
if req.Type == "" {
req.Type = "text"
}
content, err := validateMessageContent(req.Type, req.Content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
session, ok := loadTenantSession(c, id)
if !ok {
@@ -50,7 +66,7 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
SessionID: session.ID,
SenderType: "agent",
SenderID: &userID,
Content: req.Content,
Content: content,
Type: req.Type,
SentAt: time.Now(),
}
@@ -59,6 +75,10 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
return
}
if middleware.GetRole(c) == "agent" {
model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq)
session.LastReadSeq = msg.Seq
}
broadcastSessionMessage(session, msg)
middleware.JSON(c, msg)
@@ -134,6 +154,14 @@ func loadAssignableAgent(tenantID, agentID uint) error {
return nil
}
func unreadCount(session model.Session) int {
var count int64
model.DB.Model(&model.Message{}).
Where("session_id = ? AND sender_type = ? AND seq > ?", session.ID, "visitor", session.LastReadSeq).
Count(&count)
return int(count)
}
func (h *SessionHandler) List(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
page, pageSize := middleware.GetPageParams(c)
@@ -155,9 +183,21 @@ func (h *SessionHandler) List(c *gin.Context) {
}
query.Model(&model.Session{}).Count(&total)
query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions)
if err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话失败"})
return
}
middleware.JSONList(c, sessions, total, page, pageSize)
items := make([]SessionListItem, 0, len(sessions))
for _, session := range sessions {
item := SessionListItem{Session: session}
if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) {
item.UnreadCount = unreadCount(session)
}
items = append(items, item)
}
middleware.JSONList(c, items, total, page, pageSize)
}
func (h *SessionHandler) Get(c *gin.Context) {
@@ -179,8 +219,81 @@ func (h *SessionHandler) Get(c *gin.Context) {
}
var events []model.SessionEvent
model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events)
var pendingCount int64
model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount)
middleware.JSON(c, gin.H{"session": session, "messages": messages, "events": events})
middleware.JSON(c, gin.H{"session": session, "messages": messages, "events": events, "pending_count": pendingCount})
}
func (h *SessionHandler) MarkRead(c *gin.Context) {
session, ok := loadTenantSession(c, c.Param("id"))
if !ok {
return
}
if !canReadSession(c, session) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权标记该会话已读"})
return
}
if session.AgentID == nil || middleware.GetRole(c) != "agent" || *session.AgentID != middleware.GetUserID(c) {
middleware.JSON(c, gin.H{"last_read_seq": session.LastReadSeq})
return
}
var maxSeq int
if err := model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "标记已读失败"})
return
}
if err := model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", maxSeq).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "标记已读失败"})
return
}
middleware.JSON(c, gin.H{"last_read_seq": maxSeq})
}
func (h *SessionHandler) AddNote(c *gin.Context) {
session, ok := loadTenantSession(c, c.Param("id"))
if !ok {
return
}
if !canOperateSession(c, session) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权添加内部备注"})
return
}
var req CreateNoteReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
content := strings.TrimSpace(req.Content)
if content == "" || utf8.RuneCountInString(content) > 500 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "内部备注需为 1 至 500 字"})
return
}
event := model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "note", Detail: content}
if err := model.DB.Create(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存内部备注失败"})
return
}
middleware.JSON(c, event)
}
func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
type agentItem struct {
ID uint `json:"id"`
Nickname string `json:"nickname"`
Status string `json:"status"`
}
var users []model.User
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", middleware.GetTenantID(c), "agent", "online").
Order("nickname asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"})
return
}
items := make([]agentItem, 0, len(users))
for _, user := range users {
items = append(items, agentItem{ID: user.ID, Nickname: user.Nickname, Status: user.Status})
}
middleware.JSON(c, items)
}
func (h *SessionHandler) Create(c *gin.Context) {
@@ -222,6 +335,8 @@ func (h *SessionHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"})
return
}
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, tenantID).
Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()})
middleware.JSON(c, session)
}
@@ -259,7 +374,7 @@ func (h *SessionHandler) Assign(c *gin.Context) {
result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting").
Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active"})
Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active", "last_read_seq": 0})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "分配失败"})
@@ -299,7 +414,7 @@ func (h *SessionHandler) Transfer(c *gin.Context) {
}
result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active").
Update("agent_id", req.AgentID)
Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"})
return
@@ -336,6 +451,10 @@ func (h *SessionHandler) End(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"})
return
}
if reason != "resolved" && reason != "no_response" && reason != "visitor_left" && reason != "transferred" && reason != "other" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "结束原因无效"})
return
}
now := time.Now()
result := model.DB.Model(&model.Session{}).
+15 -11
View File
@@ -105,6 +105,8 @@ func (h *WidgetHandler) Init(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"})
return
}
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()})
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
}
@@ -150,6 +152,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
if req.Type == "" {
req.Type = "text"
}
content, err := validateMessageContent(req.Type, req.Content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
if !ok {
@@ -160,21 +167,12 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
return
}
// 如果是等待中的会话,更新为活跃
if session.Status == "waiting" {
if err := model.DB.Model(session).Update("status", "active").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新会话失败"})
return
}
session.Status = "active"
}
custID := session.CustomerID
msg := model.Message{
SessionID: session.ID,
SenderType: "visitor",
SenderID: &custID,
Content: req.Content,
Content: content,
Type: req.Type,
SentAt: time.Now(),
}
@@ -183,8 +181,14 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
return
}
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
if session.AgentID == nil {
// 等待会话的消息要通知全部客服,便于任一在线客服及时领取。
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
} else {
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
}
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})