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