diff --git a/server/go.mod b/server/go.mod index 69c253d..c7a9ec9 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/websocket v1.5.3 golang.org/x/crypto v0.28.0 gorm.io/driver/postgres v1.5.9 + gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 ) @@ -33,6 +34,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect diff --git a/server/go.sum b/server/go.sum index ac8d600..02350a7 100644 --- a/server/go.sum +++ b/server/go.sum @@ -59,6 +59,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -113,6 +115,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index c5c1e7d..941c271 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -4,9 +4,9 @@ import ( "net/http" "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" - "golang.org/x/crypto/bcrypt" ) type AuthHandler struct{} @@ -18,14 +18,6 @@ type LoginReq struct { Password string `json:"password" binding:"required"` } -type RegisterReq struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` - Nickname string `json:"nickname" binding:"required"` - TenantID uint `json:"tenant_id" binding:"required"` - Role string `json:"role"` -} - func (h *AuthHandler) Login(c *gin.Context) { var req LoginReq if err := c.ShouldBindJSON(&req); err != nil { @@ -44,8 +36,8 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - if user.Status == "disabled" { - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "账号已被禁用"}) + if status, message := middleware.ValidateUserAccess(user.ID, user.TenantID, user.Role); status != 0 { + c.JSON(status, gin.H{"code": status, "message": message}) return } @@ -67,37 +59,3 @@ func (h *AuthHandler) Login(c *gin.Context) { }, }) } - -func (h *AuthHandler) Register(c *gin.Context) { - var req RegisterReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) - return - } - - if req.Role == "" { - req.Role = "agent" - } - - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加密失败"}) - return - } - - user := model.User{ - Username: req.Username, - PasswordHash: string(hash), - Nickname: req.Nickname, - TenantID: req.TenantID, - Role: req.Role, - Status: "online", - } - - if err := model.DB.Create(&user).Error; err != nil { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "用户名已存在"}) - return - } - - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "注册成功"}) -} diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index 8cbad9e..b898a8c 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -12,6 +12,20 @@ type CustomerHandler struct{} func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} } +func canAccessCustomer(c *gin.Context, customerID uint) bool { + if middleware.HasAnyRole(c, "admin", "supervisor") { + return true + } + if middleware.GetRole(c) != "agent" { + return false + } + var count int64 + model.DB.Model(&model.Session{}). + Where("tenant_id = ? AND customer_id = ? AND agent_id = ?", middleware.GetTenantID(c), customerID, middleware.GetUserID(c)). + Count(&count) + return count > 0 +} + func (h *CustomerHandler) List(c *gin.Context) { tenantID := middleware.GetTenantID(c) page, pageSize := middleware.GetPageParams(c) @@ -23,6 +37,12 @@ func (h *CustomerHandler) List(c *gin.Context) { var total int64 query := model.DB.Where("tenant_id = ?", tenantID) + if middleware.GetRole(c) == "agent" { + assignedCustomers := model.DB.Model(&model.Session{}). + Select("customer_id"). + Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) + query = query.Where("id IN (?)", assignedCustomers) + } if search != "" { query = query.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%") } @@ -48,10 +68,17 @@ func (h *CustomerHandler) Get(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"}) return } + if !canAccessCustomer(c, customer.ID) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"}) + return + } var sessions []model.Session - model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID). - Order("created_at desc").Limit(20).Find(&sessions) + sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID) + if middleware.GetRole(c) == "agent" { + sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c)) + } + sessionQuery.Order("created_at desc").Limit(20).Find(&sessions) middleware.JSON(c, gin.H{"customer": customer, "sessions": sessions}) } @@ -82,6 +109,10 @@ func (h *CustomerHandler) Update(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"}) return } + if !canAccessCustomer(c, customer.ID) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"}) + return + } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { @@ -89,15 +120,30 @@ func (h *CustomerHandler) Update(c *gin.Context) { return } - // 不允许修改 tenant_id - delete(updates, "tenant_id") - delete(updates, "id") + allowed := map[string]bool{"name": true, "phone": true, "email": true, "tags": true, "source": true, "status": true} + for key := range updates { + if !allowed[key] { + delete(updates, key) + } + } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) + return + } - model.DB.Model(&customer).Updates(updates) + if err := model.DB.Model(&customer).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) + return + } + model.DB.First(&customer, customer.ID) middleware.JSON(c, customer) } func (h *CustomerHandler) Delete(c *gin.Context) { + if !middleware.HasAnyRole(c, "admin", "supervisor") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"}) + return + } tenantID := middleware.GetTenantID(c) id := c.Param("id") diff --git a/server/internal/handler/knowledge.go b/server/internal/handler/knowledge.go index 397cf97..3eaf75d 100644 --- a/server/internal/handler/knowledge.go +++ b/server/internal/handler/knowledge.go @@ -12,6 +12,36 @@ type KnowledgeHandler struct{} func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} } +func requireKnowledgeManager(c *gin.Context) bool { + if middleware.HasAnyRole(c, "admin", "supervisor") { + return true + } + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理知识库"}) + return false +} + +func hasKnowledgeCapacity(tenantID uint) (bool, error) { + var tenant model.Tenant + if err := model.DB.First(&tenant, tenantID).Error; err != nil { + return false, err + } + if tenant.PlanID == nil { + return true, nil + } + var plan model.Plan + if err := model.DB.First(&plan, *tenant.PlanID).Error; err != nil { + return false, err + } + if plan.KBLimit == 0 { + return true, nil + } + var count int64 + if err := model.DB.Model(&model.KnowledgeEntry{}).Where("tenant_id = ?", tenantID).Count(&count).Error; err != nil { + return false, err + } + return count < int64(plan.KBLimit), nil +} + func (h *KnowledgeHandler) ListCategories(c *gin.Context) { tenantID := middleware.GetTenantID(c) @@ -22,6 +52,9 @@ func (h *KnowledgeHandler) ListCategories(c *gin.Context) { } func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { + if !requireKnowledgeManager(c) { + return + } var category model.Category if err := c.ShouldBindJSON(&category); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) @@ -65,12 +98,29 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) { } func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { + if !requireKnowledgeManager(c) { + return + } var entry model.KnowledgeEntry if err := c.ShouldBindJSON(&entry); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } entry.TenantID = middleware.GetTenantID(c) + available, err := hasKnowledgeCapacity(entry.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "校验知识库容量失败"}) + return + } + if !available { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "当前套餐知识库容量已达上限"}) + return + } + var category model.Category + if err := model.DB.Where("id = ? AND tenant_id = ?", entry.CategoryID, entry.TenantID).First(&category).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "知识分类不存在"}) + return + } if err := model.DB.Create(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) @@ -81,6 +131,9 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { } func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) { + if !requireKnowledgeManager(c) { + return + } tenantID := middleware.GetTenantID(c) id := c.Param("id") @@ -96,14 +149,36 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) { return } - delete(updates, "tenant_id") - delete(updates, "id") + allowed := map[string]bool{"category_id": true, "title": true, "content": true, "status": true} + for key := range updates { + if !allowed[key] { + delete(updates, key) + } + } + if categoryID, exists := updates["category_id"]; exists { + var category model.Category + if err := model.DB.Where("id = ? AND tenant_id = ?", categoryID, tenantID).First(&category).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "知识分类不存在"}) + return + } + } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) + return + } - model.DB.Model(&entry).Updates(updates) + if err := model.DB.Model(&entry).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) + return + } + model.DB.First(&entry, entry.ID) middleware.JSON(c, entry) } func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) { + if !requireKnowledgeManager(c) { + return + } tenantID := middleware.GetTenantID(c) id := c.Param("id") diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 89ae92d..7c18696 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -19,7 +19,6 @@ func SetupRoutes(r *gin.Engine) { // 公开接口 api.POST("/login", auth.Login) - api.POST("/register", auth.Register) // widget 接口 widgetApi := api.Group("/widget") @@ -27,6 +26,8 @@ func SetupRoutes(r *gin.Engine) { widgetApi.GET("/init", widget.Init) widgetApi.POST("/message", widget.SendMessage) widgetApi.GET("/messages", widget.GetMessages) + widgetApi.GET("/ws", widget.Connect) + widgetApi.POST("/rating", widget.SubmitRating) // 需要认证的接口 authRequired := api.Group("") @@ -67,6 +68,7 @@ func SetupRoutes(r *gin.Engine) { statistics := authRequired.Group("/statistics") statistics.GET("/kpi", stats.KPIs) statistics.GET("/trend", stats.SessionTrend) + statistics.GET("/response-distribution", stats.ResponseDistribution) statistics.GET("/performance", stats.AgentPerformance) statistics.GET("/channels", stats.ChannelDistribution) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go new file mode 100644 index 0000000..ceda422 --- /dev/null +++ b/server/internal/handler/security_integration_test.go @@ -0,0 +1,338 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "kefu-sys/server/internal/handler" + "kefu-sys/server/internal/middleware" + "kefu-sys/server/internal/model" +) + +func setupRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + dsn := fmt.Sprintf("file:kefu_handler_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := model.Migrate(db); err != nil { + t.Fatalf("迁移测试数据库失败: %v", err) + } + model.DB = db + middleware.InitJWT("test-secret") + router := gin.New() + handler.SetupRoutes(router) + return router +} + +func createTenant(t *testing.T, name, status string) model.Tenant { + t.Helper() + tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)} + if err := model.DB.Create(&tenant).Error; err != nil { + t.Fatalf("创建租户失败: %v", err) + } + return tenant +} + +func createUser(t *testing.T, tenantID uint, username, role string) model.User { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) + if err != nil { + t.Fatalf("生成密码失败: %v", err) + } + user := model.User{ + TenantID: tenantID, Username: username, PasswordHash: string(hash), + Nickname: username, Role: role, Status: "online", + } + if err := model.DB.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + return user +} + +func bearerRequest(t *testing.T, method, target string, body []byte, user model.User) *http.Request { + t.Helper() + token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role) + if err != nil { + t.Fatalf("生成令牌失败: %v", err) + } + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func TestPublicRegisterIsUnavailableAndSuspendedTenantIsBlocked(t *testing.T) { + router := setupRouter(t) + suspended := createTenant(t, "已暂停租户", "suspended") + createUser(t, suspended.ID, "suspended-agent", "agent") + + registerRecorder := httptest.NewRecorder() + router.ServeHTTP(registerRecorder, httptest.NewRequest(http.MethodPost, "/api/register", nil)) + if registerRecorder.Code != http.StatusNotFound { + t.Fatalf("公开注册接口状态码 = %d,期望 %d", registerRecorder.Code, http.StatusNotFound) + } + + loginRecorder := httptest.NewRecorder() + loginRequest := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewBufferString(`{"username":"suspended-agent","password":"password123"}`)) + loginRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusForbidden { + t.Fatalf("暂停租户登录状态码 = %d,期望 %d", loginRecorder.Code, http.StatusForbidden) + } +} + +func TestTenantSuspensionTakesEffectForExistingToken(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "正常租户", "normal") + user := createUser(t, tenant.ID, "normal-agent", "agent") + + if err := model.DB.Model(&tenant).Update("status", "suspended").Error; err != nil { + t.Fatalf("暂停租户失败: %v", err) + } + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, user)) + if recorder.Code != http.StatusForbidden { + t.Fatalf("已暂停租户的令牌请求状态码 = %d,期望 %d", recorder.Code, http.StatusForbidden) + } +} + +func TestEmptyListResponsesUseArraysInsteadOfNull(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "空列表租户", "normal") + user := createUser(t, tenant.ID, "empty-list-agent", "agent") + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, user)) + if recorder.Code != http.StatusOK { + t.Fatalf("查询空会话列表状态码 = %d,响应 = %s", recorder.Code, recorder.Body.String()) + } + var response struct { + List []model.Session `json:"list"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("解析空列表响应失败: %v", err) + } + if response.List == nil { + t.Fatalf("空会话列表被序列化为 null: %s", recorder.Body.String()) + } +} + +func TestWidgetRequiresVisitorTokenAndDoesNotFallbackChannel(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "Widget 租户", "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) + } + + invalidRecorder := httptest.NewRecorder() + router.ServeHTTP(invalidRecorder, httptest.NewRequest(http.MethodPost, "/api/widget/init?channel_key=WK_unknown_001", nil)) + if invalidRecorder.Code != http.StatusNotFound { + t.Fatalf("未知渠道状态码 = %d,期望 %d", invalidRecorder.Code, http.StatusNotFound) + } + + initRecorder := httptest.NewRecorder() + initRequest := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_secure_001","visitor_name":"测试访客"}`)) + initRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(initRecorder, initRequest) + if initRecorder.Code != http.StatusOK { + t.Fatalf("初始化 Widget 状态码 = %d,响应 = %s", initRecorder.Code, initRecorder.Body.String()) + } + var initResponse struct { + Code int `json:"code"` + Data struct { + SessionID uint `json:"session_id"` + VisitorToken string `json:"visitor_token"` + } `json:"data"` + } + if err := json.Unmarshal(initRecorder.Body.Bytes(), &initResponse); err != nil { + t.Fatalf("解析初始化响应失败: %v", err) + } + if initResponse.Data.SessionID == 0 || initResponse.Data.VisitorToken == "" { + t.Fatalf("初始化未返回会话私密凭证: %s", initRecorder.Body.String()) + } + + unauthorizedRecorder := httptest.NewRecorder() + router.ServeHTTP(unauthorizedRecorder, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResponse.Data.SessionID), nil)) + if unauthorizedRecorder.Code != http.StatusUnauthorized { + t.Fatalf("无凭证读取消息状态码 = %d,期望 %d", unauthorizedRecorder.Code, http.StatusUnauthorized) + } + + sendRecorder := httptest.NewRecorder() + sendRequest := httptest.NewRequest(http.MethodPost, "/api/widget/message", bytes.NewBufferString(fmt.Sprintf(`{"session_id":%d,"content":"需要帮助"}`, initResponse.Data.SessionID))) + sendRequest.Header.Set("Content-Type", "application/json") + sendRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken) + router.ServeHTTP(sendRecorder, sendRequest) + if sendRecorder.Code != http.StatusOK { + t.Fatalf("携带凭证发送消息状态码 = %d,响应 = %s", sendRecorder.Code, sendRecorder.Body.String()) + } + + readRecorder := httptest.NewRecorder() + readRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResponse.Data.SessionID), nil) + readRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken) + router.ServeHTTP(readRecorder, readRequest) + if readRecorder.Code != http.StatusOK { + t.Fatalf("携带凭证读取消息状态码 = %d,响应 = %s", readRecorder.Code, readRecorder.Body.String()) + } + if err := model.DB.Model(&model.Session{}).Where("id = ?", initResponse.Data.SessionID).Update("status", "ended").Error; err != nil { + t.Fatalf("准备评价会话失败: %v", err) + } + ratingRecorder := httptest.NewRecorder() + ratingRequest := httptest.NewRequest(http.MethodPost, "/api/widget/rating", bytes.NewBufferString(fmt.Sprintf(`{"session_id":%d,"score":5,"text":"服务很好"}`, initResponse.Data.SessionID))) + ratingRequest.Header.Set("Content-Type", "application/json") + ratingRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken) + router.ServeHTTP(ratingRecorder, ratingRequest) + if ratingRecorder.Code != http.StatusOK { + t.Fatalf("提交评价状态码 = %d,响应 = %s", ratingRecorder.Code, ratingRecorder.Body.String()) + } + var ratedSession model.Session + if err := model.DB.First(&ratedSession, initResponse.Data.SessionID).Error; err != nil || ratedSession.SatisfactionScore == nil || *ratedSession.SatisfactionScore != 5 { + t.Fatalf("会话评分未正确保存: session=%+v err=%v", ratedSession, err) + } +} + +func TestAgentCannotAccessOtherTenantSessionAndCanEndOwnSession(t *testing.T) { + router := setupRouter(t) + tenantA := createTenant(t, "租户 A", "normal") + tenantB := createTenant(t, "租户 B", "normal") + agentA := createUser(t, tenantA.ID, "agent-a", "agent") + agentA2 := createUser(t, tenantA.ID, "agent-a2", "agent") + agentB := createUser(t, tenantB.ID, "agent-b", "agent") + customerA := model.Customer{TenantID: tenantA.ID, Name: "客户 A"} + customerB := model.Customer{TenantID: tenantB.ID, Name: "客户 B"} + if err := model.DB.Create(&customerA).Error; err != nil { + t.Fatalf("创建客户 A 失败: %v", err) + } + if err := model.DB.Create(&customerB).Error; err != nil { + t.Fatalf("创建客户 B 失败: %v", err) + } + sessionA := model.Session{TenantID: tenantA.ID, CustomerID: customerA.ID, AgentID: &agentA.ID, Status: "active", Priority: "normal"} + sessionA2 := model.Session{TenantID: tenantA.ID, CustomerID: customerA.ID, AgentID: &agentA2.ID, Status: "active", Priority: "normal"} + sessionB := model.Session{TenantID: tenantB.ID, CustomerID: customerB.ID, AgentID: &agentB.ID, Status: "active", Priority: "normal"} + if err := model.DB.Create(&sessionA).Error; err != nil { + t.Fatalf("创建会话 A 失败: %v", err) + } + if err := model.DB.Create(&sessionB).Error; err != nil { + t.Fatalf("创建会话 B 失败: %v", err) + } + if err := model.DB.Create(&sessionA2).Error; err != nil { + t.Fatalf("创建会话 A2 失败: %v", err) + } + + crossTenantRecorder := httptest.NewRecorder() + router.ServeHTTP(crossTenantRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/sessions/%d", sessionB.ID), nil, agentA)) + if crossTenantRecorder.Code != http.StatusForbidden { + t.Fatalf("跨租户读取会话状态码 = %d,期望 %d", crossTenantRecorder.Code, http.StatusForbidden) + } + + otherAgentRecorder := httptest.NewRecorder() + router.ServeHTTP(otherAgentRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/sessions/%d", sessionA2.ID), nil, agentA)) + if otherAgentRecorder.Code != http.StatusForbidden { + t.Fatalf("读取同租户其他客服会话状态码 = %d,期望 %d", otherAgentRecorder.Code, http.StatusForbidden) + } + + endRecorder := httptest.NewRecorder() + router.ServeHTTP(endRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/end?reason=resolved", sessionA.ID), []byte(`{}`), agentA)) + if endRecorder.Code != http.StatusOK { + t.Fatalf("结束自己的会话状态码 = %d,响应 = %s", endRecorder.Code, endRecorder.Body.String()) + } + var ended model.Session + if err := model.DB.First(&ended, sessionA.ID).Error; err != nil { + t.Fatalf("读取已结束会话失败: %v", err) + } + if ended.Status != "ended" || ended.EndedAt == nil { + t.Fatalf("会话结束字段未正确写入: %+v", ended) + } +} + +func TestStatisticsAreCalculatedFromPersistedSessionsAndMessages(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "统计租户", "normal") + supervisor := createUser(t, tenant.ID, "stats-supervisor", "supervisor") + agent := createUser(t, tenant.ID, "stats-agent", "agent") + customer := model.Customer{TenantID: tenant.ID, Name: "统计客户"} + if err := model.DB.Create(&customer).Error; err != nil { + t.Fatalf("创建统计客户失败: %v", err) + } + score := 5 + now := time.Now() + session := model.Session{ + TenantID: tenant.ID, CustomerID: customer.ID, AgentID: &agent.ID, Status: "ended", Priority: "normal", + EndReason: "resolved", EndedAt: &now, SatisfactionScore: &score, + } + 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: now.Add(-30 * time.Second)} + agentMessage := model.Message{SessionID: session.ID, SenderType: "agent", SenderID: &agent.ID, Content: "回复", Type: "text", Seq: 2, SentAt: now.Add(-10 * time.Second)} + if err := model.DB.Create(&[]model.Message{visitorMessage, agentMessage}).Error; err != nil { + t.Fatalf("创建统计消息失败: %v", err) + } + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/statistics/kpi", nil, supervisor)) + if recorder.Code != http.StatusOK { + t.Fatalf("查询统计状态码 = %d,响应 = %s", recorder.Code, recorder.Body.String()) + } + var response struct { + Code int `json:"code"` + Data struct { + TotalSessions int `json:"total_sessions"` + TotalMessages int `json:"total_messages"` + AvgResponseTime float64 `json:"avg_response_time"` + SatisfactionAvg float64 `json:"satisfaction_avg"` + FirstResolveRate float64 `json:"first_resolve_rate"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("解析统计响应失败: %v", err) + } + if response.Data.TotalSessions != 1 || response.Data.TotalMessages != 2 || response.Data.AvgResponseTime != 20 || response.Data.SatisfactionAvg != 5 || response.Data.FirstResolveRate != 100 { + t.Fatalf("统计值不正确: %+v", response.Data) + } +} + +func TestKnowledgeEntryRespectsPlanCapacity(t *testing.T) { + router := setupRouter(t) + plan := model.Plan{Name: "测试套餐", KBLimit: 1, Status: "active"} + if err := model.DB.Create(&plan).Error; err != nil { + t.Fatalf("创建套餐失败: %v", err) + } + tenant := createTenant(t, "容量租户", "normal") + if err := model.DB.Model(&tenant).Update("plan_id", plan.ID).Error; err != nil { + t.Fatalf("关联套餐失败: %v", err) + } + supervisor := createUser(t, tenant.ID, "knowledge-supervisor", "supervisor") + category := model.Category{TenantID: tenant.ID, Name: "常见问题"} + if err := model.DB.Create(&category).Error; err != nil { + t.Fatalf("创建分类失败: %v", err) + } + entry := model.KnowledgeEntry{TenantID: tenant.ID, CategoryID: category.ID, Title: "已有条目", Content: "内容"} + if err := model.DB.Create(&entry).Error; err != nil { + t.Fatalf("创建已有条目失败: %v", err) + } + + recorder := httptest.NewRecorder() + body := []byte(fmt.Sprintf(`{"category_id":%d,"title":"超额条目","content":"内容"}`, category.ID)) + router.ServeHTTP(recorder, bearerRequest(t, http.MethodPost, "/api/knowledge/entries", body, supervisor)) + if recorder.Code != http.StatusConflict { + t.Fatalf("超出知识库容量状态码 = %d,期望 %d,响应 = %s", recorder.Code, http.StatusConflict, recorder.Body.String()) + } +} diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 125de3a..207c149 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -5,8 +5,10 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" + "kefu-sys/server/internal/ws" ) type SessionHandler struct{} @@ -19,7 +21,6 @@ type SendMessageReq struct { } func (h *SessionHandler) SendMessage(c *gin.Context) { - tenantID := middleware.GetTenantID(c) userID := middleware.GetUserID(c) id := c.Param("id") @@ -32,14 +33,18 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { req.Type = "text" } - var session model.Session - if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) + session, ok := loadTenantSession(c, id) + if !ok { + return + } + if !canOperateSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权向该会话发送消息"}) + return + } + if session.Status == "ended" || session.Status == "archived" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"}) return } - - var maxSeq int - model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq) msg := model.Message{ SessionID: session.ID, @@ -47,14 +52,14 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { SenderID: &userID, Content: req.Content, Type: req.Type, - Seq: maxSeq + 1, SentAt: time.Now(), } - if err := model.DB.Create(&msg).Error; err != nil { + if err := model.CreateMessage(&msg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) return } + broadcastSessionMessage(session, msg) middleware.JSON(c, msg) } @@ -66,7 +71,67 @@ type CreateSessionReq struct { } type AssignSessionReq struct { - AgentID uint `json:"agent_id" binding:"required"` + AgentID uint `json:"agent_id"` +} + +func isTenantManager(c *gin.Context) bool { + return middleware.HasAnyRole(c, "admin", "supervisor") +} + +func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) { + var session model.Session + if err := model.DB.First(&session, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) + return nil, false + } + if session.TenantID != middleware.GetTenantID(c) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权访问其他租户会话"}) + return nil, false + } + return &session, true +} + +func canReadSession(c *gin.Context, session *model.Session) bool { + if isTenantManager(c) { + return true + } + if middleware.GetRole(c) == "agent" { + userID := middleware.GetUserID(c) + return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID) + } + return false +} + +func canOperateSession(c *gin.Context, session *model.Session) bool { + if isTenantManager(c) { + return true + } + return middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) +} + +func broadcastSessionMessage(session *model.Session, message model.Message) { + payload, err := ws.NewEvent("message", session.ID, message) + if err == nil { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } +} + +func broadcastSessionUpdate(session *model.Session) { + payload, err := ws.NewEvent("session_updated", session.ID, gin.H{"status": session.Status}) + if err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } +} + +func loadAssignableAgent(tenantID, agentID uint) error { + var agent model.User + if err := model.DB.First(&agent, agentID).Error; err != nil { + return err + } + if agent.TenantID != tenantID || agent.Role != "agent" || agent.Status == "disabled" { + return gorm.ErrRecordNotFound + } + return nil } func (h *SessionHandler) List(c *gin.Context) { @@ -79,6 +144,9 @@ func (h *SessionHandler) List(c *gin.Context) { var total int64 query := model.DB.Where("tenant_id = ?", tenantID) + if middleware.GetRole(c) == "agent" { + query = query.Where("agent_id = ? OR status = ?", middleware.GetUserID(c), "waiting") + } if status != "" { query = query.Where("status = ?", status) } @@ -93,30 +161,53 @@ func (h *SessionHandler) List(c *gin.Context) { } func (h *SessionHandler) Get(c *gin.Context) { - tenantID := middleware.GetTenantID(c) id := c.Param("id") - var session model.Session - if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) + session, ok := loadTenantSession(c, id) + if !ok { + return + } + if !canReadSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该会话"}) return } var messages []model.Message - model.DB.Where("session_id = ?", session.ID).Order("seq asc").Find(&messages) + if err := model.DB.Where("session_id = ?", session.ID).Order("seq asc").Find(&messages).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) + return + } + var events []model.SessionEvent + model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events) - middleware.JSON(c, gin.H{"session": session, "messages": messages}) + middleware.JSON(c, gin.H{"session": session, "messages": messages, "events": events}) } func (h *SessionHandler) Create(c *gin.Context) { + if !isTenantManager(c) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可创建会话"}) + return + } var req CreateSessionReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } + tenantID := middleware.GetTenantID(c) + var channel model.Channel + if err := model.DB.Where("id = ? AND tenant_id = ? AND status = ?", req.ChannelID, tenantID, "enabled").First(&channel).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道不存在或未启用"}) + return + } + var customer model.Customer + if err := model.DB.Where("id = ? AND tenant_id = ?", req.CustomerID, tenantID).First(&customer).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户不存在"}) + return + } + session := model.Session{ - TenantID: middleware.GetTenantID(c), + TenantID: tenantID, ChannelID: req.ChannelID, CustomerID: req.CustomerID, Priority: req.Priority, @@ -136,57 +227,120 @@ func (h *SessionHandler) Create(c *gin.Context) { } func (h *SessionHandler) Assign(c *gin.Context) { - tenantID := middleware.GetTenantID(c) id := c.Param("id") + session, ok := loadTenantSession(c, id) + if !ok { + return + } var req AssignSessionReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } + if session.Status != "waiting" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"}) + return + } + if middleware.GetRole(c) == "agent" { + req.AgentID = middleware.GetUserID(c) + } else if !isTenantManager(c) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权分配会话"}) + return + } + if req.AgentID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择目标客服"}) + return + } + if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"}) + return + } + result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ? AND status = ?", id, tenantID, "waiting"). + Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting"). Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active"}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "分配失败"}) + return + } if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在或已被分配"}) + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"}) return } + model.DB.Create(&model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "assign", Detail: "会话分配"}) + session.AgentID = &req.AgentID + session.Status = "active" + broadcastSessionUpdate(session) middleware.JSON(c, gin.H{"message": "分配成功"}) } func (h *SessionHandler) Transfer(c *gin.Context) { - tenantID := middleware.GetTenantID(c) id := c.Param("id") + session, ok := loadTenantSession(c, id) + if !ok { + return + } + if !canOperateSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权转接该会话"}) + return + } var req AssignSessionReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ?", id, tenantID). + if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"}) + return + } + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). Update("agent_id", req.AgentID) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"}) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话不可转接"}) + return + } model.DB.Create(&model.SessionEvent{ - SessionID: parseID(id), + SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "transfer", Detail: "会话转接", }) + session.AgentID = &req.AgentID + broadcastSessionUpdate(session) middleware.JSON(c, gin.H{"message": "转接成功"}) } func (h *SessionHandler) End(c *gin.Context) { - tenantID := middleware.GetTenantID(c) id := c.Param("id") reason := c.Query("reason") + session, ok := loadTenantSession(c, id) + if !ok { + return + } + if !canOperateSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权结束该会话"}) + return + } + if reason == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"}) + return + } + now := time.Now() result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ?", id, tenantID). - Updates(map[string]interface{}{"status": "ended", "end_reason": reason}) + Where("id = ? AND tenant_id = ? AND status <> ?", session.ID, session.TenantID, "ended"). + Updates(map[string]interface{}{"status": "ended", "end_reason": reason, "ended_at": now}) if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) @@ -194,33 +348,43 @@ func (h *SessionHandler) End(c *gin.Context) { } model.DB.Create(&model.SessionEvent{ - SessionID: parseID(id), + SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "end", Detail: "结束会话: " + reason, }) + session.Status = "ended" + session.EndedAt = &now + broadcastSessionUpdate(session) middleware.JSON(c, gin.H{"message": "已结束"}) } func (h *SessionHandler) UpdatePriority(c *gin.Context) { - tenantID := middleware.GetTenantID(c) id := c.Param("id") priority := c.Query("priority") + session, ok := loadTenantSession(c, id) + if !ok { + return + } + if !canOperateSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权更新该会话"}) + return + } + if priority != "urgent" && priority != "normal" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "优先级无效"}) + return + } - model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ?", id, tenantID). + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ?", session.ID, session.TenantID). Update("priority", priority) + if result.Error != nil || result.RowsAffected == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) + return + } + session.Priority = priority + broadcastSessionUpdate(session) middleware.JSON(c, gin.H{"message": "已更新"}) } - -func parseID(s string) uint { - var id uint - for _, c := range s { - if c >= '0' && c <= '9' { - id = id*10 + uint(c-'0') - } - } - return id -} diff --git a/server/internal/handler/statistics.go b/server/internal/handler/statistics.go index a27dcf5..3295f4a 100644 --- a/server/internal/handler/statistics.go +++ b/server/internal/handler/statistics.go @@ -1,6 +1,10 @@ package handler import ( + "net/http" + "sort" + "time" + "github.com/gin-gonic/gin" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" @@ -10,69 +14,308 @@ type StatisticsHandler struct{} func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} } +func requireStatisticsAccess(c *gin.Context) bool { + if middleware.HasAnyRole(c, "admin", "supervisor") { + return true + } + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"}) + return false +} + +func loadStatisticsData(tenantID uint) ([]model.Session, []model.Message, error) { + var sessions []model.Session + if err := model.DB.Where("tenant_id = ?", tenantID).Order("created_at asc").Find(&sessions).Error; err != nil { + return nil, nil, err + } + if len(sessions) == 0 { + return sessions, []model.Message{}, nil + } + ids := make([]uint, 0, len(sessions)) + for _, session := range sessions { + ids = append(ids, session.ID) + } + var messages []model.Message + if err := model.DB.Where("session_id IN ?", ids).Order("session_id asc, seq asc").Find(&messages).Error; err != nil { + return nil, nil, err + } + return sessions, messages, nil +} + +func firstResponseSeconds(messages []model.Message) map[uint]float64 { + visitorFirst := make(map[uint]time.Time) + responseSeconds := make(map[uint]float64) + for _, message := range messages { + if message.SenderType == "visitor" { + if _, exists := visitorFirst[message.SessionID]; !exists { + visitorFirst[message.SessionID] = message.SentAt + } + continue + } + if message.SenderType != "agent" { + continue + } + firstVisitorAt, hasVisitor := visitorFirst[message.SessionID] + if !hasVisitor || !message.SentAt.After(firstVisitorAt) { + continue + } + if _, exists := responseSeconds[message.SessionID]; !exists { + responseSeconds[message.SessionID] = message.SentAt.Sub(firstVisitorAt).Seconds() + } + } + return responseSeconds +} + +func firstResolveRate(sessions []model.Session) float64 { + endedCount := 0 + resolvedCount := 0 + for index, session := range sessions { + if session.Status != "ended" { + continue + } + endedCount++ + if session.EndReason != "resolved" && session.EndReason != "已解决" { + continue + } + endedAt := session.CreatedAt + if session.EndedAt != nil { + endedAt = *session.EndedAt + } + reopened := false + for _, later := range sessions[index+1:] { + if later.CustomerID == session.CustomerID && later.CreatedAt.After(endedAt) && later.CreatedAt.Sub(endedAt) <= 24*time.Hour { + reopened = true + break + } + } + if !reopened { + resolvedCount++ + } + } + if endedCount == 0 { + return 0 + } + return float64(resolvedCount) * 100 / float64(endedCount) +} + func (h *StatisticsHandler) KPIs(c *gin.Context) { - tenantID := middleware.GetTenantID(c) - - var totalSessions, totalMessages int64 - var avgResponseTime float64 - var satisfactionAvg float64 - var firstResolveRate float64 - - model.DB.Model(&model.Session{}).Where("tenant_id = ?", tenantID).Count(&totalSessions) - model.DB.Model(&model.Message{}). - Joins("JOIN sessions ON messages.session_id = sessions.id"). - Where("sessions.tenant_id = ?", tenantID). - Count(&totalMessages) + if !requireStatisticsAccess(c) { + return + } + sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"}) + return + } + responses := firstResponseSeconds(messages) + var responseTotal float64 + for _, seconds := range responses { + responseTotal += seconds + } + var satisfactionTotal float64 + satisfactionCount := 0 + for _, session := range sessions { + if session.SatisfactionScore != nil { + satisfactionTotal += float64(*session.SatisfactionScore) + satisfactionCount++ + } + } + avgResponse := float64(0) + if len(responses) > 0 { + avgResponse = responseTotal / float64(len(responses)) + } + satisfactionAvg := float64(0) + if satisfactionCount > 0 { + satisfactionAvg = satisfactionTotal / float64(satisfactionCount) + } middleware.JSON(c, gin.H{ - "total_sessions": totalSessions, - "avg_response_time": avgResponseTime, + "total_sessions": len(sessions), + "avg_response_time": avgResponse, "satisfaction_avg": satisfactionAvg, - "first_resolve_rate": firstResolveRate, - "total_messages": totalMessages, + "first_resolve_rate": firstResolveRate(sessions), + "total_messages": len(messages), }) } func (h *StatisticsHandler) SessionTrend(c *gin.Context) { - tenantID := middleware.GetTenantID(c) + if !requireStatisticsAccess(c) { + return + } period := c.DefaultQuery("period", "day") - - _ = tenantID - var data []gin.H - if period == "day" { - data = []gin.H{ - {"date": "07-08", "count": 420}, {"date": "07-09", "count": 380}, - {"date": "07-10", "count": 450}, {"date": "07-11", "count": 520}, - {"date": "07-12", "count": 490}, {"date": "07-13", "count": 550}, - {"date": "07-14", "count": 610}, + var sessions []model.Session + if err := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)).Find(&sessions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话趋势失败"}) + return + } + now := time.Now() + data := make([]gin.H, 0) + if period == "today" { + count := 0 + for _, session := range sessions { + if session.CreatedAt.Format("2006-01-02") == now.Format("2006-01-02") { + count++ + } + } + data = append(data, gin.H{"date": now.Format("01-02"), "count": count}) + } else if period == "month" { + counts := make(map[string]int) + for _, session := range sessions { + counts[session.CreatedAt.Format("2006-01")]++ + } + for offset := 5; offset >= 0; offset-- { + month := time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, now.Location()) + key := month.Format("2006-01") + data = append(data, gin.H{"date": key, "count": counts[key]}) } } else { - data = []gin.H{ - {"date": "06", "count": 12500}, {"date": "07", "count": 13800}, + counts := make(map[string]int) + for _, session := range sessions { + counts[session.CreatedAt.Format("01-02")]++ + } + for offset := 6; offset >= 0; offset-- { + day := now.AddDate(0, 0, -offset) + key := day.Format("01-02") + data = append(data, gin.H{"date": key, "count": counts[key]}) } } + middleware.JSON(c, data) +} +func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) { + if !requireStatisticsAccess(c) { + return + } + _, messages, err := loadStatisticsData(middleware.GetTenantID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"}) + return + } + counts := map[string]int{"0-10s": 0, "10-30s": 0, "30-60s": 0, "1-3min": 0, ">3min": 0} + for _, seconds := range firstResponseSeconds(messages) { + switch { + case seconds <= 10: + counts["0-10s"]++ + case seconds <= 30: + counts["10-30s"]++ + case seconds <= 60: + counts["30-60s"]++ + case seconds <= 180: + counts["1-3min"]++ + default: + counts[">3min"]++ + } + } + data := []gin.H{} + for _, key := range []string{"0-10s", "10-30s", "30-60s", "1-3min", ">3min"} { + data = append(data, gin.H{"range": key, "count": counts[key]}) + } middleware.JSON(c, data) } func (h *StatisticsHandler) AgentPerformance(c *gin.Context) { + if !requireStatisticsAccess(c) { + return + } tenantID := middleware.GetTenantID(c) - _ = tenantID - - data := []gin.H{ - {"name": "客服小王", "conversations": 420, "avg_response": 28, "satisfaction": 4.9}, - {"name": "客服小李", "conversations": 380, "avg_response": 35, "satisfaction": 4.7}, - {"name": "客服小张", "conversations": 350, "avg_response": 42, "satisfaction": 4.5}, + sessions, messages, err := loadStatisticsData(tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"}) + return + } + var agents []model.User + if err := model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"}) + return + } + responses := firstResponseSeconds(messages) + type performance struct { + ID uint + Name string + Conversations int + ResponseTotal float64 + ResponseCount int + Satisfaction float64 + RatingCount int + } + items := make(map[uint]*performance) + for _, agent := range agents { + items[agent.ID] = &performance{ID: agent.ID, Name: agent.Nickname} + } + for _, session := range sessions { + if session.AgentID == nil || items[*session.AgentID] == nil { + continue + } + item := items[*session.AgentID] + item.Conversations++ + if seconds, exists := responses[session.ID]; exists { + item.ResponseTotal += seconds + item.ResponseCount++ + } + if session.SatisfactionScore != nil { + item.Satisfaction += float64(*session.SatisfactionScore) + item.RatingCount++ + } + } + values := make([]*performance, 0, len(items)) + for _, item := range items { + values = append(values, item) + } + sort.Slice(values, func(i, j int) bool { return values[i].Conversations > values[j].Conversations }) + data := make([]gin.H, 0, len(values)) + for _, item := range values { + avgResponse := float64(0) + if item.ResponseCount > 0 { + avgResponse = item.ResponseTotal / float64(item.ResponseCount) + } + satisfaction := float64(0) + if item.RatingCount > 0 { + satisfaction = item.Satisfaction / float64(item.RatingCount) + } + data = append(data, gin.H{"name": item.Name, "conversations": item.Conversations, "avg_response": avgResponse, "satisfaction": satisfaction}) } - middleware.JSON(c, data) } func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) { - data := []gin.H{ - {"type": "网页", "value": 45}, {"type": "微信", "value": 28}, - {"type": "APP", "value": 18}, {"type": "电话", "value": 6}, {"type": "邮件", "value": 3}, + if !requireStatisticsAccess(c) { + return + } + tenantID := middleware.GetTenantID(c) + var sessions []model.Session + var channels []model.Channel + if err := model.DB.Where("tenant_id = ?", tenantID).Find(&sessions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) + return + } + if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) + return + } + channelTypes := make(map[uint]string) + for _, channel := range channels { + channelTypes[channel.ID] = channel.Type + } + labels := map[string]string{"web": "网页", "wechat": "微信", "app": "APP", "phone": "电话工单", "email": "邮件"} + counts := make(map[string]int) + for _, session := range sessions { + channelType := channelTypes[session.ChannelID] + if channelType == "" { + channelType = "unknown" + } + counts[channelType]++ + } + keys := make([]string, 0, len(counts)) + for key := range counts { + keys = append(keys, key) + } + sort.Strings(keys) + data := make([]gin.H, 0, len(keys)) + for _, key := range keys { + label := labels[key] + if label == "" { + label = key + } + data = append(data, gin.H{"type": label, "value": counts[key]}) } - middleware.JSON(c, data) } diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index fcc6efb..b0b8281 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -1,11 +1,18 @@ package handler import ( + "errors" "net/http" + "regexp" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" + "kefu-sys/server/internal/ws" ) type WidgetHandler struct{} @@ -13,33 +20,52 @@ type WidgetHandler struct{} func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} } type WidgetInitReq struct { - ChannelKey string `json:"channel_key" form:"channel_key"` + ChannelKey string `json:"channel_key" form:"channel_key"` VisitorName string `json:"visitor_name"` } type WidgetMessageReq struct { - SessionID uint `json:"session_id" binding:"required"` - Content string `json:"content" binding:"required"` - Type string `json:"type"` + SessionID uint `json:"session_id" binding:"required"` + Content string `json:"content" binding:"required"` + Type string `json:"type"` + VisitorToken string `json:"visitor_token"` } +type WidgetRatingReq struct { + SessionID uint `json:"session_id" binding:"required"` + Score int `json:"score" binding:"required"` + Text string `json:"text"` + VisitorToken string `json:"visitor_token"` +} + +var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`) + func (h *WidgetHandler) Init(c *gin.Context) { var req WidgetInitReq - if err := c.ShouldBindQuery(&req); err != nil && c.ShouldBindJSON(&req) != nil { + if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } + if c.Request.Method == http.MethodPost && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + } if req.ChannelKey == "" { req.ChannelKey = c.Query("channel_key") } + if !channelKeyPattern.MatchString(req.ChannelKey) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道标识无效"}) + return + } var channel model.Channel - if err := model.DB.Where("script_code LIKE ?", "%"+req.ChannelKey+"%").Or("script_code LIKE ?", "%"+req.ChannelKey+"%").First(&channel).Error; err != nil { - // 如果没有匹配的渠道,使用第一个启用的渠道 - if err := model.DB.Where("type = ? AND status = ?", "web", "enabled").First(&channel).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"}) - return - } + if err := model.DB. + Where("type = ? AND status = ? AND script_code LIKE ?", "web", "enabled", "%data-id=\""+req.ChannelKey+"\"%"). + First(&channel).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在或已关闭"}) + return } name := req.VisitorName @@ -60,27 +86,61 @@ func (h *WidgetHandler) Init(c *gin.Context) { model.DB.Create(&customer) } + visitorToken, visitorTokenHash, err := model.NewVisitorToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建访客会话失败"}) + return + } + // 创建会话 session := model.Session{ - TenantID: channel.TenantID, - ChannelID: channel.ID, - CustomerID: customer.ID, - Status: "waiting", - Priority: "normal", + TenantID: channel.TenantID, + ChannelID: channel.ID, + CustomerID: customer.ID, + VisitorTokenHash: visitorTokenHash, + Status: "waiting", + Priority: "normal", + } + if err := model.DB.Create(&session).Error; 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) } - model.DB.Create(&session) 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, + "session_id": session.ID, + "customer_id": customer.ID, + "channel_id": channel.ID, + "tenant_id": channel.TenantID, + "visitor_token": visitorToken, }, }) } +func visitorTokenFromRequest(c *gin.Context, bodyToken string) string { + if token := c.GetHeader("X-Visitor-Token"); token != "" { + return token + } + return bodyToken +} + +func loadVisitorSession(c *gin.Context, sessionID uint, token string) (*model.Session, bool) { + var session model.Session + if err := model.DB.First(&session, sessionID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) + return nil, false + } + if !model.VerifyVisitorToken(&session, token) { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "访客会话凭证无效"}) + return nil, false + } + return &session, true +} + func (h *WidgetHandler) SendMessage(c *gin.Context) { var req WidgetMessageReq if err := c.ShouldBindJSON(&req); err != nil { @@ -91,20 +151,24 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { req.Type = "text" } - var session model.Session - if err := model.DB.First(&session, req.SessionID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) + 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 } // 如果是等待中的会话,更新为活跃 if session.Status == "waiting" { - model.DB.Model(&session).Update("status", "active") + 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" } - var maxSeq int - model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq) - custID := session.CustomerID msg := model.Message{ SessionID: session.ID, @@ -112,23 +176,110 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { SenderID: &custID, Content: req.Content, Type: req.Type, - Seq: maxSeq + 1, SentAt: time.Now(), } - if err := model.DB.Create(&msg).Error; err != nil { + if err := model.CreateMessage(&msg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) return } + if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) } +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" { + return strings.TrimSpace(protocols[1]) + } + return "" +} + +func (h *WidgetHandler) Connect(c *gin.Context) { + sessionID, err := strconv.ParseUint(c.Query("session_id"), 10, 64) + if err != nil || sessionID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) + return + } + session, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromWebSocket(c)) + if !ok { + return + } + client, err := ws.UpgradeVisitor(c.Writer, c.Request, session.TenantID, session.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"}) + return + } + ws.HandleWebSocket(client) +} + +func (h *WidgetHandler) SubmitRating(c *gin.Context) { + var req WidgetRatingReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + if req.Score < 1 || req.Score > 5 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "评分应为 1 至 5 星"}) + return + } + session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if !ok { + return + } + if session.Status != "ended" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话结束后才能评价"}) + return + } + if session.SatisfactionScore != nil { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该会话已评价"}) + return + } + + if err := model.DB.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Session{}).Where("id = ? AND satisfaction_score IS NULL", session.ID). + Updates(map[string]interface{}{"satisfaction_score": req.Score, "satisfaction_text": req.Text}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). + Updates(map[string]interface{}{ + "satisfaction_sum": gorm.Expr("satisfaction_sum + ?", req.Score), + "satisfaction_count": gorm.Expr("satisfaction_count + 1"), + }).Error + }); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该会话已评价"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "提交评价失败"}) + return + } + + middleware.JSON(c, gin.H{"message": "感谢您的评价"}) +} + func (h *WidgetHandler) GetMessages(c *gin.Context) { - sessionID := c.Query("session_id") + sessionID, err := strconv.ParseUint(c.Query("session_id"), 10, 64) + if err != nil || sessionID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) + return + } + if _, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromRequest(c, "")); !ok { + return + } var messages []model.Message - model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages) + if err := model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) + return + } c.JSON(http.StatusOK, gin.H{"code": 0, "data": messages}) } diff --git a/server/internal/handler/ws.go b/server/internal/handler/ws.go index 405dcd4..5abf336 100644 --- a/server/internal/handler/ws.go +++ b/server/internal/handler/ws.go @@ -15,9 +15,8 @@ func NewWsHandler() *WsHandler { return &WsHandler{} } func (h *WsHandler) Connect(c *gin.Context) { userID := middleware.GetUserID(c) tenantID := middleware.GetTenantID(c) - role, _ := c.Get("role") - client, err := ws.Upgrade(c.Writer, c.Request, userID, tenantID, role.(string)) + client, err := ws.UpgradeAgent(c.Writer, c.Request, userID, tenantID, middleware.GetRole(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"}) return diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index fc39850..98d6e31 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -1,13 +1,16 @@ package middleware import ( + "errors" "net/http" + "reflect" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "kefu-sys/server/internal/model" ) var jwtSecret []byte @@ -37,26 +40,91 @@ func GenerateToken(userID, tenantID uint, role string) (string, error) { return token.SignedString(jwtSecret) } +func ParseToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if t.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, errors.New("不支持的签名算法") + } + return jwtSecret, nil + }) + if err != nil || !token.Valid { + if err == nil { + err = errors.New("token无效") + } + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, errors.New("token声明无效") + } + return claims, nil +} + +// ValidateUserAccess 确保令牌对应的账号与租户当前仍可使用。 +func ValidateUserAccess(userID, tenantID uint, role string) (int, string) { + var user model.User + if err := model.DB.Select("id", "tenant_id", "role", "status").First(&user, userID).Error; err != nil || + user.TenantID != tenantID || user.Role != role { + return http.StatusUnauthorized, "账号状态已变化,请重新登录" + } + if user.Status == "disabled" { + return http.StatusForbidden, "账号已被禁用" + } + + if role == "platform_admin" { + if tenantID != 0 { + return http.StatusUnauthorized, "平台管理员租户无效" + } + return 0, "" + } + + var tenant model.Tenant + if err := model.DB.Select("id", "status").First(&tenant, tenantID).Error; err != nil { + return http.StatusForbidden, "租户不存在或不可用" + } + if tenant.Status == "suspended" || tenant.Status == "expired" { + return http.StatusForbidden, "租户已暂停或已过期" + } + return 0, "" +} + +func tokenFromRequest(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + + // 浏览器 WebSocket 无法设置 Authorization;仅接受约定子协议中的令牌。 + protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",") + if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-v1" { + return strings.TrimSpace(protocols[1]) + } + return "" +} + func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { - auth := c.GetHeader("Authorization") - if auth == "" || !strings.HasPrefix(auth, "Bearer ") { + tokenStr := tokenFromRequest(c) + if tokenStr == "" { c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未授权"}) c.Abort() return } - tokenStr := strings.TrimPrefix(auth, "Bearer ") - token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { - return jwtSecret, nil - }) - if err != nil || !token.Valid { + claims, err := ParseToken(tokenStr) + if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "token无效"}) c.Abort() return } - claims := token.Claims.(*Claims) + if status, message := ValidateUserAccess(claims.UserID, claims.TenantID, claims.Role); status != 0 { + c.JSON(status, gin.H{"code": status, "message": message}) + c.Abort() + return + } + c.Set("user_id", claims.UserID) c.Set("tenant_id", claims.TenantID) c.Set("role", claims.Role) @@ -64,10 +132,25 @@ func AuthRequired() gin.HandlerFunc { } } +func GetRole(c *gin.Context) string { + role, _ := c.Get("role") + value, _ := role.(string) + return value +} + +func HasAnyRole(c *gin.Context, roles ...string) bool { + role := GetRole(c) + for _, allowed := range roles { + if role == allowed { + return true + } + } + return false +} + func AdminRequired() gin.HandlerFunc { return func(c *gin.Context) { - role, _ := c.Get("role") - if role != "admin" && role != "platform_admin" { + if !HasAnyRole(c, "admin", "platform_admin") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权限"}) c.Abort() return @@ -78,8 +161,7 @@ func AdminRequired() gin.HandlerFunc { func PlatformRequired() gin.HandlerFunc { return func(c *gin.Context) { - role, _ := c.Get("role") - if role != "platform_admin" { + if !HasAnyRole(c, "platform_admin") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅平台管理员可操作"}) c.Abort() return @@ -111,16 +193,25 @@ func GetPageParams(c *gin.Context) (page, pageSize int) { } func JSON(c *gin.Context, data interface{}) { - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": data}) + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nonNilSlice(data)}) } func JSONList(c *gin.Context, list interface{}, total int64, page, pageSize int) { c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "ok", - "list": list, + "list": nonNilSlice(list), "total": total, "page": page, "pageSize": pageSize, }) } + +// nonNilSlice 统一将空切片序列化为 [],避免前端收到 null 后调用数组方法崩溃。 +func nonNilSlice(data interface{}) interface{} { + value := reflect.ValueOf(data) + if value.IsValid() && value.Kind() == reflect.Slice && value.IsNil() { + return reflect.MakeSlice(value.Type(), 0, 0).Interface() + } + return data +} diff --git a/server/internal/model/db.go b/server/internal/model/db.go index 1b4aaaa..a2d47d0 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -19,7 +19,16 @@ func InitDB(dsn string) { log.Fatalf("数据库连接失败: %v", err) } - err = DB.AutoMigrate( + err = Migrate(DB) + if err != nil { + log.Fatalf("数据库迁移失败: %v", err) + } + + log.Println("数据库迁移完成") +} + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate( &Tenant{}, &User{}, &Channel{}, @@ -33,9 +42,4 @@ func InitDB(dsn string) { &OperationLog{}, &Announcement{}, ) - if err != nil { - log.Fatalf("数据库迁移失败: %v", err) - } - - log.Println("数据库迁移完成") } diff --git a/server/internal/model/message.go b/server/internal/model/message.go new file mode 100644 index 0000000..7fc36dc --- /dev/null +++ b/server/internal/model/message.go @@ -0,0 +1,34 @@ +package model + +import ( + "errors" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrSessionNotFound = errors.New("会话不存在") + +// CreateMessage 在会话行锁保护下分配消息序号,避免并发写入出现重复序号。 +func CreateMessage(message *Message) error { + return DB.Transaction(func(tx *gorm.DB) error { + var session Session + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&session, message.SessionID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSessionNotFound + } + return err + } + + var maxSeq int + if err := tx.Model(&Message{}). + Where("session_id = ?", message.SessionID). + Select("COALESCE(MAX(seq), 0)"). + Scan(&maxSeq).Error; err != nil { + return err + } + + message.Seq = maxSeq + 1 + return tx.Create(message).Error + }) +} diff --git a/server/internal/model/message_test.go b/server/internal/model/message_test.go new file mode 100644 index 0000000..60f0b82 --- /dev/null +++ b/server/internal/model/message_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "fmt" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCreateMessageAssignsMonotonicSequence(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:kefu_message_%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := Migrate(db); err != nil { + t.Fatalf("迁移测试数据库失败: %v", err) + } + DB = db + + tenant := Tenant{Name: "消息测试租户", Status: "normal"} + if err := DB.Create(&tenant).Error; err != nil { + t.Fatalf("创建租户失败: %v", err) + } + customer := Customer{TenantID: tenant.ID, Name: "测试客户"} + if err := DB.Create(&customer).Error; err != nil { + t.Fatalf("创建客户失败: %v", err) + } + session := Session{TenantID: tenant.ID, CustomerID: customer.ID, Status: "active", Priority: "normal"} + if err := DB.Create(&session).Error; err != nil { + t.Fatalf("创建会话失败: %v", err) + } + + first := Message{SessionID: session.ID, SenderType: "visitor", Content: "第一条", Type: "text", SentAt: time.Now()} + second := Message{SessionID: session.ID, SenderType: "agent", Content: "第二条", Type: "text", SentAt: time.Now()} + if err := CreateMessage(&first); err != nil { + t.Fatalf("创建第一条消息失败: %v", err) + } + if err := CreateMessage(&second); err != nil { + t.Fatalf("创建第二条消息失败: %v", err) + } + if first.Seq != 1 || second.Seq != 2 { + t.Fatalf("消息序号异常: first=%d second=%d", first.Seq, second.Seq) + } + + duplicate := Message{SessionID: session.ID, SenderType: "agent", Content: "重复序号", Type: "text", Seq: 2, SentAt: time.Now()} + if err := DB.Create(&duplicate).Error; err == nil { + t.Fatal("重复会话消息序号未被唯一索引拦截") + } +} diff --git a/server/internal/model/models.go b/server/internal/model/models.go index c090c0f..917cad0 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -7,96 +7,97 @@ import ( ) type Tenant struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null;uniqueIndex" json:"name"` - PlanID *uint `json:"plan_id"` - SeatCount int `gorm:"default:2" json:"seat_count"` - ExpireAt time.Time `json:"expire_at"` - Status string `gorm:"size:20;default:normal" json:"status"` - ContactName string `gorm:"size:30" json:"contact_name"` - ContactPhone string `gorm:"size:20" json:"contact_phone"` - ContactEmail string `gorm:"size:100" json:"contact_email"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null;uniqueIndex" json:"name"` + PlanID *uint `json:"plan_id"` + SeatCount int `gorm:"default:2" json:"seat_count"` + ExpireAt time.Time `json:"expire_at"` + Status string `gorm:"size:20;default:normal" json:"status"` + ContactName string `gorm:"size:30" json:"contact_name"` + ContactPhone string `gorm:"size:20" json:"contact_phone"` + ContactEmail string `gorm:"size:100" json:"contact_email"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type User struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - Role string `gorm:"size:20;default:agent" json:"role"` - Username string `gorm:"size:50;not null;uniqueIndex" json:"username"` - PasswordHash string `gorm:"size:255;not null" json:"-"` - Nickname string `gorm:"size:50" json:"nickname"` - Status string `gorm:"size:20;default:online" json:"status"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + Role string `gorm:"size:20;default:agent" json:"role"` + Username string `gorm:"size:50;not null;uniqueIndex" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Nickname string `gorm:"size:50" json:"nickname"` + Status string `gorm:"size:20;default:online" json:"status"` LastOnlineAt *time.Time `json:"last_online_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type Channel struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - Type string `gorm:"size:30;not null" json:"type"` - Name string `gorm:"size:50" json:"name"` - Status string `gorm:"size:20;default:enabled" json:"status"` - Config string `gorm:"type:text" json:"config"` - ScriptCode string `gorm:"size:500" json:"script_code"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + Type string `gorm:"size:30;not null" json:"type"` + Name string `gorm:"size:50" json:"name"` + Status string `gorm:"size:20;default:enabled" json:"status"` + Config string `gorm:"type:text" json:"config"` + ScriptCode string `gorm:"size:500" json:"script_code"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type Customer struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - Name string `gorm:"size:50;not null" json:"name"` - Phone string `gorm:"size:20" json:"phone"` - Email string `gorm:"size:100" json:"email"` - Tags string `gorm:"type:text" json:"tags"` - Source string `gorm:"size:30" json:"source"` - Status string `gorm:"size:20;default:online" json:"status"` - ConversationCount int `gorm:"default:0" json:"conversation_count"` - SatisfactionSum float64 `gorm:"default:0" json:"-"` - SatisfactionCount int `gorm:"default:0" json:"-"` - LastContactAt *time.Time `json:"last_contact_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + Name string `gorm:"size:50;not null" json:"name"` + Phone string `gorm:"size:20" json:"phone"` + Email string `gorm:"size:100" json:"email"` + Tags string `gorm:"type:text" json:"tags"` + Source string `gorm:"size:30" json:"source"` + Status string `gorm:"size:20;default:online" json:"status"` + ConversationCount int `gorm:"default:0" json:"conversation_count"` + SatisfactionSum float64 `gorm:"default:0" json:"-"` + SatisfactionCount int `gorm:"default:0" json:"-"` + LastContactAt *time.Time `json:"last_contact_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type Session struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - ChannelID uint `json:"channel_id"` - CustomerID uint `gorm:"index" json:"customer_id"` - AgentID *uint `gorm:"index" json:"agent_id"` - Status string `gorm:"size:20;default:waiting" json:"status"` - Priority string `gorm:"size:20;default:normal" json:"priority"` - SatisfactionScore *int `json:"satisfaction_score"` - SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` - EndReason string `gorm:"size:50" json:"end_reason"` - CreatedAt time.Time `json:"created_at"` - EndedAt *time.Time `json:"ended_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + ChannelID uint `json:"channel_id"` + CustomerID uint `gorm:"index" json:"customer_id"` + AgentID *uint `gorm:"index" json:"agent_id"` + VisitorTokenHash string `gorm:"size:64;index" json:"-"` + Status string `gorm:"size:20;default:waiting" json:"status"` + Priority string `gorm:"size:20;default:normal" json:"priority"` + SatisfactionScore *int `json:"satisfaction_score"` + SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` + EndReason string `gorm:"size:50" json:"end_reason"` + CreatedAt time.Time `json:"created_at"` + EndedAt *time.Time `json:"ended_at"` + UpdatedAt time.Time `json:"updated_at"` } type Message struct { ID uint `gorm:"primaryKey" json:"id"` - SessionID uint `gorm:"index;not null" json:"session_id"` + SessionID uint `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"session_id"` SenderType string `gorm:"size:20;not null" json:"sender_type"` SenderID *uint `json:"sender_id"` Content string `gorm:"type:text;not null" json:"content"` Type string `gorm:"size:20;default:text" json:"type"` - Seq int `gorm:"not null" json:"seq"` + Seq int `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"seq"` SentAt time.Time `json:"sent_at"` } type SessionEvent struct { - ID uint `gorm:"primaryKey" json:"id"` - SessionID uint `gorm:"index;not null" json:"session_id"` - OperatorID uint `json:"operator_id"` - Action string `gorm:"size:50;not null" json:"action"` - Detail string `gorm:"size:500" json:"detail"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey" json:"id"` + SessionID uint `gorm:"index;not null" json:"session_id"` + OperatorID uint `json:"operator_id"` + Action string `gorm:"size:50;not null" json:"action"` + Detail string `gorm:"size:500" json:"detail"` + CreatedAt time.Time `json:"created_at"` } type Category struct { @@ -119,16 +120,16 @@ type KnowledgeEntry struct { } type Plan struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:30;not null" json:"name"` - PriceMonthly int `json:"price_monthly"` - Seats int `json:"seats"` - StorageDays int `json:"storage_days"` - KBLimit int `json:"kb_limit"` - Features string `gorm:"type:text" json:"features"` - Status string `gorm:"size:20;default:active" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:30;not null" json:"name"` + PriceMonthly int `json:"price_monthly"` + Seats int `json:"seats"` + StorageDays int `json:"storage_days"` + KBLimit int `json:"kb_limit"` + Features string `gorm:"type:text" json:"features"` + Status string `gorm:"size:20;default:active" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type OperationLog struct { diff --git a/server/internal/model/visitor_token.go b/server/internal/model/visitor_token.go new file mode 100644 index 0000000..fb7311e --- /dev/null +++ b/server/internal/model/visitor_token.go @@ -0,0 +1,30 @@ +package model + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" +) + +func NewVisitorToken() (string, string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", "", err + } + + token := base64.RawURLEncoding.EncodeToString(bytes) + return token, HashVisitorToken(token), nil +} + +func HashVisitorToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func VerifyVisitorToken(session *Session, token string) bool { + if token == "" || session.VisitorTokenHash == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(session.VisitorTokenHash), []byte(HashVisitorToken(token))) == 1 +} diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index a402fd8..36f30a3 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -11,31 +11,29 @@ import ( ) var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { return true }, + Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"}, } type Client struct { - Conn *websocket.Conn - UserID uint - TenantID uint - Role string - Send chan []byte + Conn *websocket.Conn + UserID uint + TenantID uint + Role string + Kind string + SessionID *uint + Send chan []byte } -type Message struct { - Type string `json:"type"` - SessionID uint `json:"session_id"` - Content string `json:"content,omitempty"` - FromID uint `json:"from_id,omitempty"` - FromName string `json:"from_name,omitempty"` - TenantID uint `json:"tenant_id"` - Seq int `json:"seq,omitempty"` - Timestamp int64 `json:"timestamp"` +type Event struct { + Type string `json:"type"` + SessionID uint `json:"session_id,omitempty"` + Data interface{} `json:"data,omitempty"` + Timestamp int64 `json:"timestamp"` } type Hub struct { clients map[*Client]bool - broadcast chan []byte register chan *Client unregister chan *Client mu sync.RWMutex @@ -46,12 +44,20 @@ var DefaultHub = NewHub() func NewHub() *Hub { return &Hub{ clients: make(map[*Client]bool), - broadcast: make(chan []byte, 256), register: make(chan *Client), unregister: make(chan *Client), } } +func NewEvent(eventType string, sessionID uint, data interface{}) ([]byte, error) { + return json.Marshal(Event{ + Type: eventType, + SessionID: sessionID, + Data: data, + Timestamp: time.Now().UnixMilli(), + }) +} + func (h *Hub) Run() { for { select { @@ -67,93 +73,117 @@ func (h *Hub) Run() { close(client.Send) } h.mu.Unlock() - - case msg := <-h.broadcast: - h.mu.RLock() - for client := range h.clients { - select { - case client.Send <- msg: - default: - close(client.Send) - delete(h.clients, client) - } - } - h.mu.RUnlock() } } } -func (h *Hub) BroadcastToTenant(tenantID uint, msg []byte) { +func (h *Hub) send(client *Client, message []byte) { + select { + case client.Send <- message: + default: + } +} + +// BroadcastToSession 仅把消息推送给当前客服、主管/管理员及该会话的访客。 +func (h *Hub) BroadcastToSession(tenantID, sessionID uint, agentID *uint, message []byte) { h.mu.RLock() defer h.mu.RUnlock() + for client := range h.clients { - if client.TenantID == tenantID { - select { - case client.Send <- msg: - default: - } - } - } -} - -func HandleWebSocket(c *Client) { - conn := c.Conn - - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case msg, ok := <-c.Send: - if !ok { - conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { - return - } - case <-ticker.C: - conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - } - } - }() - - for { - _, msgBytes, err := conn.ReadMessage() - if err != nil { - break - } - - var msg Message - if err := json.Unmarshal(msgBytes, &msg); err != nil { + if client.TenantID != tenantID { continue } - msg.TenantID = c.TenantID - msg.FromID = c.UserID - msg.Timestamp = time.Now().UnixMilli() - - reply, _ := json.Marshal(msg) - DefaultHub.BroadcastToTenant(c.TenantID, reply) + if client.Kind == "visitor" { + if client.SessionID != nil && *client.SessionID == sessionID { + h.send(client, message) + } + continue + } + if client.Role == "admin" || client.Role == "supervisor" || + (agentID != nil && client.Role == "agent" && client.UserID == *agentID) { + h.send(client, message) + } } } -func Upgrade(w http.ResponseWriter, r *http.Request, userID, tenantID uint, role string) (*Client, error) { +// BroadcastToTenantStaff 只通知租户内的工作人员,不向访客泄露其他会话事件。 +func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + + for client := range h.clients { + if client.TenantID == tenantID && client.Kind == "agent" { + h.send(client, message) + } + } +} + +func HandleWebSocket(client *Client) { + defer func() { + DefaultHub.unregister <- client + client.Conn.Close() + }() + + go writePump(client) + for { + if _, _, err := client.Conn.ReadMessage(); err != nil { + return + } + } +} + +func writePump(client *Client) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case message, ok := <-client.Send: + if !ok { + client.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + client.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := client.Conn.WriteMessage(websocket.TextMessage, message); err != nil { + return + } + case <-ticker.C: + client.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func upgrade(w http.ResponseWriter, r *http.Request, client *Client) (*Client, error) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return nil, err } + client.Conn = conn + DefaultHub.register <- client + return client, nil +} + +func UpgradeAgent(w http.ResponseWriter, r *http.Request, userID, tenantID uint, role string) (*Client, error) { client := &Client{ - Conn: conn, UserID: userID, TenantID: tenantID, Role: role, + Kind: "agent", Send: make(chan []byte, 256), } - DefaultHub.register <- client log.Printf("WebSocket 连接: user=%d tenant=%d", userID, tenantID) - return client, nil + return upgrade(w, r, client) +} + +func UpgradeVisitor(w http.ResponseWriter, r *http.Request, tenantID, sessionID uint) (*Client, error) { + client := &Client{ + TenantID: tenantID, + Kind: "visitor", + SessionID: &sessionID, + Send: make(chan []byte, 256), + } + log.Printf("访客 WebSocket 连接: session=%d tenant=%d", sessionID, tenantID) + return upgrade(w, r, client) } diff --git a/server/internal/ws/ws_test.go b/server/internal/ws/ws_test.go new file mode 100644 index 0000000..454b6b5 --- /dev/null +++ b/server/internal/ws/ws_test.go @@ -0,0 +1,41 @@ +package ws + +import ( + "testing" + "time" +) + +func TestBroadcastToSessionRestrictsRecipients(t *testing.T) { + hub := NewHub() + go hub.Run() + + sessionID := uint(12) + agentID := uint(7) + assignedAgent := &Client{TenantID: 1, UserID: agentID, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)} + otherAgent := &Client{TenantID: 1, UserID: 8, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)} + supervisor := &Client{TenantID: 1, UserID: 9, Role: "supervisor", Kind: "agent", Send: make(chan []byte, 1)} + visitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &sessionID, Send: make(chan []byte, 1)} + otherSessionID := uint(13) + otherVisitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &otherSessionID, Send: make(chan []byte, 1)} + otherTenant := &Client{TenantID: 2, UserID: 7, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)} + + for _, client := range []*Client{assignedAgent, otherAgent, supervisor, visitor, otherVisitor, otherTenant} { + hub.register <- client + } + + hub.BroadcastToSession(1, sessionID, &agentID, []byte(`{"type":"message"}`)) + for _, client := range []*Client{assignedAgent, supervisor, visitor} { + select { + case <-client.Send: + case <-time.After(time.Second): + t.Fatalf("应收到会话消息的客户端未收到:%+v", client) + } + } + for _, client := range []*Client{otherAgent, otherVisitor, otherTenant} { + select { + case <-client.Send: + t.Fatalf("不应收到会话消息的客户端收到消息:%+v", client) + default: + } + } +} diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx index 86bac3a..667b7d7 100644 --- a/web/src/components/RequireAuth.tsx +++ b/web/src/components/RequireAuth.tsx @@ -7,4 +7,32 @@ const RequireAuth = () => { return } +export const RequirePlatformAdmin = () => { + const { user } = useAuth() + if (!user) return + if (user.role !== 'platform_admin') return + return +} + +export const RequireStaff = () => { + const { user } = useAuth() + if (!user) return + if (user.role === 'platform_admin') return + return +} + +export const RequireSupervisor = () => { + const { user } = useAuth() + if (!user) return + if (user.role !== 'admin' && user.role !== 'supervisor') return + return +} + +export const RequireTenantAdmin = () => { + const { user } = useAuth() + if (!user) return + if (user.role !== 'admin') return + return +} + export default RequireAuth diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 2ac0f81..78909cb 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -21,8 +21,13 @@ const AgentSidebar = () => { const location = useLocation() const navigate = useNavigate() const { user, logout } = useAuth() + const visibleMenuItems = menuItems.filter(item => { + if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor' + if (item.key === '/agent/settings') return user?.role === 'admin' + return true + }) - const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' + const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' const handleLogout = () => { logout() @@ -38,7 +43,7 @@ const AgentSidebar = () => { navigate(key)} className="border-e-0 mt-2 flex-1" /> diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 76c7283..e4d6538 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -13,9 +13,9 @@ const Login = () => { const onFinish = async (values: { username: string; password: string }) => { setLoading(true) try { - await login(values.username, values.password) + const user = await login(values.username, values.password) message.success('登录成功') - navigate('/agent/dashboard', { replace: true }) + navigate(user.role === 'platform_admin' ? '/admin/dashboard' : '/agent/dashboard', { replace: true }) } catch { message.error('用户名或密码错误') } finally { diff --git a/web/src/pages/agent/ChatHistory.tsx b/web/src/pages/agent/ChatHistory.tsx index 0a43d06..1ac9859 100644 --- a/web/src/pages/agent/ChatHistory.tsx +++ b/web/src/pages/agent/ChatHistory.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Input, Select, Tag, Empty, Spin } from 'antd' import { SearchOutlined, UserOutlined } from '@ant-design/icons' -import { getSessions, type Session as SessionType } from '@/services/api' +import { getSession, getSessions, type Session as SessionType } from '@/services/api' const statusColors: Record = { active: 'blue', ended: 'green', archived: 'default' } const statusLabels: Record = { active: '进行中', ended: '已结束', waiting: '等待中' } @@ -12,6 +12,8 @@ const ChatHistory = () => { const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState() const [selectedId, setSelectedId] = useState(null) + const [messages, setMessages] = useState<{ id: number; sender_type: string; content: string; sent_at: string }[]>([]) + const [detailLoading, setDetailLoading] = useState(false) useEffect(() => { loadSessions() }, [statusFilter]) @@ -26,6 +28,18 @@ const ChatHistory = () => { const selected = sessions.find(s => s.id === selectedId) + useEffect(() => { + if (!selectedId) { + setMessages([]) + return + } + setDetailLoading(true) + getSession(selectedId).then(res => { + const detail = res.data as { messages?: { id: number; sender_type: string; content: string; sent_at: string }[] } + setMessages(detail.messages || []) + }).catch(() => setMessages([])).finally(() => setDetailLoading(false)) + }, [selectedId]) + return (
@@ -74,7 +88,15 @@ const ChatHistory = () => { )}
-
消息内容通过 WebSocket 实时传输
+ {detailLoading ?
: messages.length === 0 ? : messages.map(message => ( +
+
+
{message.sender_type === 'agent' ? '客服' : '访客'}
+
{message.content}
+
{new Date(message.sent_at).toLocaleString('zh-CN')}
+
+
+ ))}
) :
选择一个对话查看详情
} diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 481eed5..a1a3ad7 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Input, Spin, message as antMsg } from 'antd' import { SearchOutlined, StarFilled } from '@ant-design/icons' import { useAuth } from '@/stores/auth' -import { getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api' +import { endSession, getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api' const priorityColors: Record = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' } const priorityLabels: Record = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' } @@ -21,21 +21,21 @@ const Dashboard = () => { const initialLoad = useRef(true) const chatEndRef = useRef(null) - useEffect(() => { loadAll() }, []) - - const loadAll = async () => { + const loadAll = useCallback(async () => { setLoading(true) try { const [sRes, cRes] = await Promise.all([ getSessions(), getCustomers({ page: 1 }), ]) - setSessions(sRes.list) + const sessionList = Array.isArray(sRes.list) ? sRes.list : [] + const customerList = Array.isArray(cRes.list) ? cRes.list : [] + setSessions(sessionList) const map: Record = {} - cRes.list.forEach(c => { map[c.id] = c }) + customerList.forEach(c => { map[c.id] = c }) setCustomers(map) - if (sRes.list.length > 0 && initialLoad.current) { - setSelectedId(sRes.list[0].id) + if (sessionList.length > 0 && initialLoad.current) { + setSelectedId(sessionList[0].id) initialLoad.current = false } } catch (err) { @@ -43,20 +43,16 @@ const Dashboard = () => { } finally { setLoading(false) } - } + }, []) - useEffect(() => { - if (selectedId) loadDetail(selectedId) - }, [selectedId]) - - const loadDetail = async (id: number) => { + const loadDetail = useCallback(async (id: number) => { setDetailLoading(true) try { const res = await getSession(id) const msgs: any = res.data as any setDetail({ messages: (msgs.messages || []).map((m: any) => ({ - sender: m.sender_type === 'agent' ? '客服' : getCustomerName(id), + sender: m.sender_type === 'agent' ? '客服' : '访客', content: m.content, time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), })), @@ -67,14 +63,33 @@ const Dashboard = () => { setDetailLoading(false) } setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200) - } + }, []) - const getCustomerName = (sessionId: number): string => { - const s = sessions.find(s => s.id === sessionId) - if (!s) return '访客' - const c = customers[s.customer_id] - return c ? c.name : `客户${s.customer_id}` - } + useEffect(() => { loadAll() }, [loadAll]) + + useEffect(() => { + if (!user?.token) return + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token]) + socket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) + if (payload.type === 'message' && payload.session_id === selectedId) { + loadDetail(payload.session_id) + } + if (payload.type === 'session_created' || payload.type === 'session_updated') { + loadAll() + } + } catch { + // 忽略格式错误的实时消息 + } + } + return () => socket.close() + }, [user?.token, selectedId, loadAll, loadDetail]) + + useEffect(() => { + if (selectedId) loadDetail(selectedId) + }, [selectedId, loadDetail]) const handleSend = async () => { if (!messageInput.trim() || !selectedId || sending) return @@ -110,6 +125,18 @@ const Dashboard = () => { } } + const handleEnd = async () => { + if (!selectedId || sending) return + try { + await endSession(selectedId, 'resolved') + antMsg.success('会话已结束') + await loadAll() + await loadDetail(selectedId) + } catch { + antMsg.error('结束会话失败') + } + } + const selected = sessions.find(s => s.id === selectedId) const selectedCustomer = selected ? customers[selected.customer_id] : null @@ -170,8 +197,8 @@ const Dashboard = () => { {statusLabels[selected.status]}
- 转接 - 结束 + 转接 + 结束
diff --git a/web/src/pages/agent/Statistics.tsx b/web/src/pages/agent/Statistics.tsx index 1bb762c..802fd7f 100644 --- a/web/src/pages/agent/Statistics.tsx +++ b/web/src/pages/agent/Statistics.tsx @@ -1,41 +1,55 @@ -import { useState } from 'react' -import { Card, Segmented, Row, Col } from 'antd' -import { ArrowUpOutlined, ArrowDownOutlined, ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons' +import { useEffect, useState } from 'react' +import { Card, Segmented, Row, Col, Spin } from 'antd' +import { ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons' import { Column, Line, Pie, Bar } from '@ant-design/charts' - -const kpiData = [ - { label: '总会话量', value: '12,580', change: 12.5, icon: , color: '#2563eb' }, - { label: '平均响应时长', value: '32s', change: -8.3, icon: , color: '#16a34a' }, - { label: '客户满意度', value: '4.8/5', change: 2.1, icon: , color: '#d97706' }, - { label: '首次解决率', value: '86%', change: 5.7, icon: , color: '#0891b2' }, -] - -const sessionTrendData = [ - { date: '07-08', count: 420 }, { date: '07-09', count: 380 }, { date: '07-10', count: 450 }, - { date: '07-11', count: 520 }, { date: '07-12', count: 490 }, { date: '07-13', count: 550 }, - { date: '07-14', count: 610 }, -] - -const responseDistribution = [ - { range: '0-10s', count: 320 }, { range: '10-30s', count: 450 }, { range: '30-60s', count: 280 }, - { range: '1-3min', count: 180 }, { range: '>3min', count: 50 }, -] - -const channelData = [ - { type: '网页', value: 45 }, { type: '微信', value: 28 }, { type: 'APP', value: 18 }, - { type: '电话工单', value: 6 }, { type: '邮件', value: 3 }, -] - -const agentPerformance = [ - { name: '客服小王', conversations: 420, avgResponse: 28, satisfaction: 4.9 }, - { name: '客服小李', conversations: 380, avgResponse: 35, satisfaction: 4.7 }, - { name: '客服小张', conversations: 350, avgResponse: 42, satisfaction: 4.5 }, - { name: '客服小赵', conversations: 290, avgResponse: 30, satisfaction: 4.8 }, - { name: '客服小刘', conversations: 220, avgResponse: 55, satisfaction: 4.2 }, -] +import { getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, type StatisticsKpis } from '@/services/api' const Statistics = () => { - const [timeRange, setTimeRange] = useState('week') + const [timeRange, setTimeRange] = useState<'today' | 'week' | 'month'>('week') + const [kpis, setKpis] = useState(null) + const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([]) + const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([]) + const [channelData, setChannelData] = useState<{ type: string; value: number }[]>([]) + const [agentPerformance, setAgentPerformance] = useState<{ name: string; conversations: number; avgResponse: number; satisfaction: number }[]>([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const load = async () => { + setLoading(true) + try { + const period = timeRange === 'week' ? 'day' : timeRange + const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([ + getKPIs(), getSessionTrend(period), getResponseDistribution(), getChannelDistribution(), getAgentPerformance(), + ]) + setKpis(kpiRes.data) + setSessionTrendData(trendRes.data) + setResponseDistribution(distributionRes.data) + setChannelData(channelRes.data) + setAgentPerformance(performanceRes.data.map(item => ({ + name: item.name, + conversations: item.conversations, + avgResponse: item.avg_response, + satisfaction: item.satisfaction, + }))) + } finally { + setLoading(false) + } + } + load().catch(() => { + setKpis(null) + setSessionTrendData([]) + setResponseDistribution([]) + setChannelData([]) + setAgentPerformance([]) + }) + }, [timeRange]) + + const kpiData = [ + { label: '总会话量', value: String(kpis?.total_sessions ?? 0), icon: , color: '#2563eb' }, + { label: '平均响应时长', value: `${Math.round(kpis?.avg_response_time ?? 0)}s`, icon: , color: '#16a34a' }, + { label: '客户满意度', value: `${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`, icon: , color: '#d97706' }, + { label: '首次解决率', value: `${(kpis?.first_resolve_rate ?? 0).toFixed(1)}%`, icon: , color: '#0891b2' }, + ] return (
@@ -43,7 +57,7 @@ const Statistics = () => {

数据统计

setTimeRange(v as string)} + onChange={v => setTimeRange(v as 'today' | 'week' | 'month')} options={[ { value: 'today', label: '今日' }, { value: 'week', label: '本周' }, @@ -52,87 +66,49 @@ const Statistics = () => { />
- {/* KPI 卡片 */} - - {kpiData.map((kpi, i) => ( - - -
- {kpi.label} - {kpi.icon} -
-
{kpi.value}
-
= 0 ? 'text-green-600' : 'text-red-500'}`}> - {kpi.change >= 0 ? : } - {Math.abs(kpi.change)}% 较上期 -
+ {loading && !kpis ?
: <> + + {kpiData.map((kpi, i) => ( + + +
+ {kpi.label} + {kpi.icon} +
+
{kpi.value}
+
基于已记录会话实时计算
+
+ + ))} +
+ + + + + - ))} - - - {/* 图表区 */} - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - + ]}} axis={{ x: { grid: true, gridStroke: '#f1f5f9' } }} /> +
+ +
+ }
) } diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index e56e6bc..4466c8f 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense } from 'react' import { Navigate, createBrowserRouter } from 'react-router-dom' import { Spin } from 'antd' -import RequireAuth from '@/components/RequireAuth' +import RequireAuth, { RequirePlatformAdmin, RequireStaff, RequireSupervisor, RequireTenantAdmin } from '@/components/RequireAuth' const AgentLayout = lazy(() => import('@/components/layout/AgentLayout')) const AdminLayout = lazy(() => import('@/components/layout/AdminLayout')) @@ -37,27 +37,43 @@ export const router = createBrowserRouter([ children: [ { path: 'admin', - element: , + element: , children: [ - { index: true, element: }, - { path: 'dashboard', element: }, - { path: 'tenants', element: }, - { path: 'plans', element: }, - { path: 'ops', element: }, + { + element: , + children: [ + { index: true, element: }, + { path: 'dashboard', element: }, + { path: 'tenants', element: }, + { path: 'plans', element: }, + { path: 'ops', element: }, + ], + }, ], }, { index: true, element: }, { path: 'agent', - element: , + element: , children: [ - { index: true, element: }, - { path: 'dashboard', element: }, - { path: 'chat-history', element: }, - { path: 'customers', element: }, - { path: 'knowledge', element: }, - { path: 'statistics', element: }, - { path: 'settings', element: }, + { + element: , + children: [ + { index: true, element: }, + { path: 'dashboard', element: }, + { path: 'chat-history', element: }, + { path: 'customers', element: }, + { path: 'knowledge', element: }, + { + element: , + children: [{ path: 'statistics', element: }], + }, + { + element: , + children: [{ path: 'settings', element: }], + }, + ], + }, ], }, ], diff --git a/web/src/services/api.ts b/web/src/services/api.ts index ea29e5c..762a337 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -23,6 +23,14 @@ export interface Tenant { contact_name: string; contact_phone: string; contact_email: string } +export interface StatisticsKpis { + total_sessions: number + avg_response_time: number + satisfaction_avg: number + first_resolve_rate: number + total_messages: number +} + // Auth export const login = (params: LoginParams) => post('/login', params) @@ -58,8 +66,11 @@ export const getKnowledgeEntries = (params?: { category_id?: string; search?: st } // Statistics -export const getKPIs = () => get>('/statistics/kpi') -export const getSessionTrend = () => get<{ date: string; count: number }[]>('/statistics/trend') +export const getKPIs = () => get('/statistics/kpi') +export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`) +export const getResponseDistribution = () => get<{ range: string; count: number }[]>('/statistics/response-distribution') +export const getChannelDistribution = () => get<{ type: string; value: number }[]>('/statistics/channels') +export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance') // Admin export const getTenants = (params?: { search?: string; status?: string; page?: number }) => { diff --git a/web/src/stores/auth.tsx b/web/src/stores/auth.tsx index 5564621..15ad2f1 100644 --- a/web/src/stores/auth.tsx +++ b/web/src/stores/auth.tsx @@ -3,15 +3,15 @@ import { setToken } from '@/services/request' import { login as loginApi, type LoginResult } from '@/services/api' interface AuthState { - user: LoginResult | null - loading: boolean - login: (username: string, password: string) => Promise + user: LoginResult | null + loading: boolean + login: (username: string, password: string) => Promise logout: () => void } const AuthContext = createContext({ - user: null, loading: false, - login: async () => {}, logout: () => {}, + user: null, loading: false, + login: async () => { throw new Error('认证上下文未初始化') }, logout: () => {}, }) export function AuthProvider({ children }: { children: ReactNode }) { @@ -31,9 +31,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const res = await loginApi({ username, password }) const u = res.data - setToken(u.token) - setUser(u) - localStorage.setItem('auth_user', JSON.stringify(u)) + setToken(u.token) + setUser(u) + localStorage.setItem('auth_user', JSON.stringify(u)) + return u } finally { setLoading(false) } diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index f137a71..810fcab 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled } from '@ant-design/icons' interface Message { @@ -10,6 +10,7 @@ interface Message { const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询'] const STORAGE_KEY = 'kefu_widget_session' +const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token' const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const [open, setOpen] = useState(defaultOpen) @@ -17,6 +18,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const saved = localStorage.getItem(STORAGE_KEY) return saved ? Number(saved) : null }) + const [visitorToken, setVisitorToken] = useState(() => localStorage.getItem(VISITOR_TOKEN_KEY) || '') const [messages, setMessages] = useState(() => { const saved = localStorage.getItem(STORAGE_KEY + '_msgs') return saved ? JSON.parse(saved) : [] @@ -25,31 +27,19 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const [sending, setSending] = useState(false) const [showRating, setShowRating] = useState(false) const [rated, setRated] = useState(false) + const [sessionEnded, setSessionEnded] = useState(false) + const [ratingText, setRatingText] = useState('') const pollRef = useRef(null) const initRef = useRef(false) - const initSession = async () => { - if (initRef.current && sessionId) return - initRef.current = true - try { - const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' }) - const json = await res.json() - if (json.code === 0) { - const sid = json.data.session_id - setSessionId(sid) - localStorage.setItem(STORAGE_KEY, String(sid)) - await loadMessages(sid) - } - } catch (e) { - console.error('Init failed:', e) - } - } - - const loadMessages = async (sid?: number) => { + const loadMessages = useCallback(async (sid?: number, token?: string) => { const s = sid || sessionId - if (!s) return + const visitorCredential = token || visitorToken || localStorage.getItem(VISITOR_TOKEN_KEY) || '' + if (!s || !visitorCredential) return try { - const res = await fetch(`/api/widget/messages?session_id=${s}`) + const res = await fetch(`/api/widget/messages?session_id=${s}`, { + headers: { 'X-Visitor-Token': visitorCredential }, + }) const json = await res.json() if (json.code === 0 && json.data && json.data.length > 0) { const msgs: Message[] = json.data.map((m: any) => ({ @@ -62,20 +52,70 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs)) } } catch { /* ignore */ } - } + }, [sessionId, visitorToken]) + + const initSession = useCallback(async () => { + if (sessionId && visitorToken) { + await loadMessages(sessionId, visitorToken) + return + } + if (initRef.current) return + initRef.current = true + try { + const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' }) + const json = await res.json() + if (json.code === 0) { + const sid = json.data.session_id + const token = json.data.visitor_token + setSessionId(sid) + setVisitorToken(token) + localStorage.setItem(STORAGE_KEY, String(sid)) + localStorage.setItem(VISITOR_TOKEN_KEY, token) + await loadMessages(sid, token) + } + } catch (e) { + console.error('Init failed:', e) + } + }, [sessionId, visitorToken, loadMessages]) useEffect(() => { - if (open && !initRef.current) { + if (open) { initSession() } - }, [open, sessionId]) + }, [open, initSession]) useEffect(() => { - if (sessionId && open) { + if (sessionId && visitorToken && open) { pollRef.current = window.setInterval(() => loadMessages(), 3000) return () => { if (pollRef.current) clearInterval(pollRef.current) } } - }, [sessionId, open]) + }, [sessionId, visitorToken, open, loadMessages]) + + useEffect(() => { + if (!sessionId || !visitorToken || !open) return + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const socket = new WebSocket( + `${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`, + ['kefu-visitor-v1', visitorToken], + ) + socket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) + if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) { + if (payload.type === 'session_updated' && payload.data?.status === 'ended') { + setSessionEnded(true) + setShowRating(true) + } + loadMessages(sessionId, visitorToken) + } + } catch { + // 忽略格式错误的实时消息 + } + } + return () => { + socket.close() + } + }, [sessionId, visitorToken, open, loadMessages]) const sendMessage = async (text: string) => { if (!text.trim() || sending) return @@ -89,11 +129,11 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } setMessages(prev => [...prev, localMsg]) - if (sessionId) { + if (sessionId && visitorToken) { try { await fetch('/api/widget/message', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken }, body: JSON.stringify({ session_id: sessionId, content, type: 'text' }), }) loadMessages(sessionId) @@ -104,13 +144,30 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const handleOpen = () => { setOpen(true) - setShowRating(false) - setRated(false) + setShowRating(sessionEnded && !rated) } const handleClose = () => { setOpen(false) - if (!rated) setShowRating(true) + if (sessionEnded && !rated) setShowRating(true) + } + + const submitRating = async (score: number) => { + if (!sessionId || !visitorToken || rated) return + try { + const res = await fetch('/api/widget/rating', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken }, + body: JSON.stringify({ session_id: sessionId, score, text: ratingText }), + }) + const json = await res.json() + if (json.code === 0) { + setRated(true) + setShowRating(false) + } + } catch { + // 评价失败时保留弹窗,允许访客稍后重试 + } } return ( @@ -173,9 +230,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }} - disabled={sending || !sessionId} + disabled={sending || !sessionId || sessionEnded} /> - sendMessage(input)} /> + sendMessage(input)} /> @@ -187,9 +244,17 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
{[1, 2, 3, 4, 5].map(star => ( { setRated(true); setShowRating(false) }} /> + onClick={() => submitRating(star)} /> ))}
+