修复会话安全与实时消息
This commit is contained in:
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
golang.org/x/crypto v0.28.0
|
golang.org/x/crypto v0.28.0
|
||||||
gorm.io/driver/postgres v1.5.9
|
gorm.io/driver/postgres v1.5.9
|
||||||
|
gorm.io/driver/sqlite v1.5.7
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ require (
|
|||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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=
|
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 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
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 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"kefu-sys/server/internal/middleware"
|
"kefu-sys/server/internal/middleware"
|
||||||
"kefu-sys/server/internal/model"
|
"kefu-sys/server/internal/model"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct{}
|
type AuthHandler struct{}
|
||||||
@@ -18,14 +18,6 @@ type LoginReq struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
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) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
var req LoginReq
|
var req LoginReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -44,8 +36,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Status == "disabled" {
|
if status, message := middleware.ValidateUserAccess(user.ID, user.TenantID, user.Role); status != 0 {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "账号已被禁用"})
|
c.JSON(status, gin.H{"code": status, "message": message})
|
||||||
return
|
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": "注册成功"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ type CustomerHandler struct{}
|
|||||||
|
|
||||||
func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} }
|
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) {
|
func (h *CustomerHandler) List(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
tenantID := middleware.GetTenantID(c)
|
||||||
page, pageSize := middleware.GetPageParams(c)
|
page, pageSize := middleware.GetPageParams(c)
|
||||||
@@ -23,6 +37,12 @@ func (h *CustomerHandler) List(c *gin.Context) {
|
|||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
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 != "" {
|
if search != "" {
|
||||||
query = query.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%", "%"+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": "客户不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !canAccessCustomer(c, customer.ID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var sessions []model.Session
|
var sessions []model.Session
|
||||||
model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID).
|
sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID)
|
||||||
Order("created_at desc").Limit(20).Find(&sessions)
|
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})
|
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": "客户不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !canAccessCustomer(c, customer.ID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var updates map[string]interface{}
|
var updates map[string]interface{}
|
||||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
@@ -89,15 +120,30 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不允许修改 tenant_id
|
allowed := map[string]bool{"name": true, "phone": true, "email": true, "tags": true, "source": true, "status": true}
|
||||||
delete(updates, "tenant_id")
|
for key := range updates {
|
||||||
delete(updates, "id")
|
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)
|
middleware.JSON(c, customer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CustomerHandler) Delete(c *gin.Context) {
|
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)
|
tenantID := middleware.GetTenantID(c)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ type KnowledgeHandler struct{}
|
|||||||
|
|
||||||
func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} }
|
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) {
|
func (h *KnowledgeHandler) ListCategories(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
|
||||||
@@ -22,6 +52,9 @@ func (h *KnowledgeHandler) ListCategories(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
|
func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
|
||||||
|
if !requireKnowledgeManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var category model.Category
|
var category model.Category
|
||||||
if err := c.ShouldBindJSON(&category); err != nil {
|
if err := c.ShouldBindJSON(&category); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
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) {
|
func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
|
||||||
|
if !requireKnowledgeManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var entry model.KnowledgeEntry
|
var entry model.KnowledgeEntry
|
||||||
if err := c.ShouldBindJSON(&entry); err != nil {
|
if err := c.ShouldBindJSON(&entry); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry.TenantID = middleware.GetTenantID(c)
|
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 {
|
if err := model.DB.Create(&entry).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
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) {
|
func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
||||||
|
if !requireKnowledgeManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
tenantID := middleware.GetTenantID(c)
|
tenantID := middleware.GetTenantID(c)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
@@ -96,14 +149,36 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(updates, "tenant_id")
|
allowed := map[string]bool{"category_id": true, "title": true, "content": true, "status": true}
|
||||||
delete(updates, "id")
|
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)
|
middleware.JSON(c, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) {
|
func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) {
|
||||||
|
if !requireKnowledgeManager(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
tenantID := middleware.GetTenantID(c)
|
tenantID := middleware.GetTenantID(c)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
|
|
||||||
// 公开接口
|
// 公开接口
|
||||||
api.POST("/login", auth.Login)
|
api.POST("/login", auth.Login)
|
||||||
api.POST("/register", auth.Register)
|
|
||||||
|
|
||||||
// widget 接口
|
// widget 接口
|
||||||
widgetApi := api.Group("/widget")
|
widgetApi := api.Group("/widget")
|
||||||
@@ -27,6 +26,8 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
widgetApi.GET("/init", widget.Init)
|
widgetApi.GET("/init", widget.Init)
|
||||||
widgetApi.POST("/message", widget.SendMessage)
|
widgetApi.POST("/message", widget.SendMessage)
|
||||||
widgetApi.GET("/messages", widget.GetMessages)
|
widgetApi.GET("/messages", widget.GetMessages)
|
||||||
|
widgetApi.GET("/ws", widget.Connect)
|
||||||
|
widgetApi.POST("/rating", widget.SubmitRating)
|
||||||
|
|
||||||
// 需要认证的接口
|
// 需要认证的接口
|
||||||
authRequired := api.Group("")
|
authRequired := api.Group("")
|
||||||
@@ -67,6 +68,7 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
statistics := authRequired.Group("/statistics")
|
statistics := authRequired.Group("/statistics")
|
||||||
statistics.GET("/kpi", stats.KPIs)
|
statistics.GET("/kpi", stats.KPIs)
|
||||||
statistics.GET("/trend", stats.SessionTrend)
|
statistics.GET("/trend", stats.SessionTrend)
|
||||||
|
statistics.GET("/response-distribution", stats.ResponseDistribution)
|
||||||
statistics.GET("/performance", stats.AgentPerformance)
|
statistics.GET("/performance", stats.AgentPerformance)
|
||||||
statistics.GET("/channels", stats.ChannelDistribution)
|
statistics.GET("/channels", stats.ChannelDistribution)
|
||||||
|
|
||||||
|
|||||||
@@ -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: `<script data-id="WK_secure_001"></script>`,
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
"kefu-sys/server/internal/middleware"
|
"kefu-sys/server/internal/middleware"
|
||||||
"kefu-sys/server/internal/model"
|
"kefu-sys/server/internal/model"
|
||||||
|
"kefu-sys/server/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SessionHandler struct{}
|
type SessionHandler struct{}
|
||||||
@@ -19,7 +21,6 @@ type SendMessageReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) SendMessage(c *gin.Context) {
|
func (h *SessionHandler) SendMessage(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
@@ -32,14 +33,18 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
|
|||||||
req.Type = "text"
|
req.Type = "text"
|
||||||
}
|
}
|
||||||
|
|
||||||
var session model.Session
|
session, ok := loadTenantSession(c, id)
|
||||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil {
|
if !ok {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxSeq int
|
|
||||||
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq)
|
|
||||||
|
|
||||||
msg := model.Message{
|
msg := model.Message{
|
||||||
SessionID: session.ID,
|
SessionID: session.ID,
|
||||||
@@ -47,14 +52,14 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
|
|||||||
SenderID: &userID,
|
SenderID: &userID,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Seq: maxSeq + 1,
|
|
||||||
SentAt: time.Now(),
|
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": "发送失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
broadcastSessionMessage(session, msg)
|
||||||
|
|
||||||
middleware.JSON(c, msg)
|
middleware.JSON(c, msg)
|
||||||
}
|
}
|
||||||
@@ -66,7 +71,67 @@ type CreateSessionReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AssignSessionReq 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) {
|
func (h *SessionHandler) List(c *gin.Context) {
|
||||||
@@ -79,6 +144,9 @@ func (h *SessionHandler) List(c *gin.Context) {
|
|||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
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 != "" {
|
if status != "" {
|
||||||
query = query.Where("status = ?", status)
|
query = query.Where("status = ?", status)
|
||||||
}
|
}
|
||||||
@@ -93,30 +161,53 @@ func (h *SessionHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) Get(c *gin.Context) {
|
func (h *SessionHandler) Get(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
var session model.Session
|
session, ok := loadTenantSession(c, id)
|
||||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil {
|
if !ok {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
return
|
||||||
|
}
|
||||||
|
if !canReadSession(c, session) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该会话"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var messages []model.Message
|
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) {
|
func (h *SessionHandler) Create(c *gin.Context) {
|
||||||
|
if !isTenantManager(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可创建会话"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var req CreateSessionReq
|
var req CreateSessionReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
return
|
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{
|
session := model.Session{
|
||||||
TenantID: middleware.GetTenantID(c),
|
TenantID: tenantID,
|
||||||
ChannelID: req.ChannelID,
|
ChannelID: req.ChannelID,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: req.CustomerID,
|
||||||
Priority: req.Priority,
|
Priority: req.Priority,
|
||||||
@@ -136,57 +227,120 @@ func (h *SessionHandler) Create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) Assign(c *gin.Context) {
|
func (h *SessionHandler) Assign(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
session, ok := loadTenantSession(c, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req AssignSessionReq
|
var req AssignSessionReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
return
|
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{}).
|
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"})
|
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 {
|
if result.RowsAffected == 0 {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在或已被分配"})
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"})
|
||||||
return
|
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": "分配成功"})
|
middleware.JSON(c, gin.H{"message": "分配成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) Transfer(c *gin.Context) {
|
func (h *SessionHandler) Transfer(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
|
||||||
id := c.Param("id")
|
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
|
var req AssignSessionReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
model.DB.Model(&model.Session{}).
|
if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil {
|
||||||
Where("id = ? AND tenant_id = ?", id, tenantID).
|
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)
|
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{
|
model.DB.Create(&model.SessionEvent{
|
||||||
SessionID: parseID(id),
|
SessionID: session.ID,
|
||||||
OperatorID: middleware.GetUserID(c),
|
OperatorID: middleware.GetUserID(c),
|
||||||
Action: "transfer",
|
Action: "transfer",
|
||||||
Detail: "会话转接",
|
Detail: "会话转接",
|
||||||
})
|
})
|
||||||
|
session.AgentID = &req.AgentID
|
||||||
|
broadcastSessionUpdate(session)
|
||||||
|
|
||||||
middleware.JSON(c, gin.H{"message": "转接成功"})
|
middleware.JSON(c, gin.H{"message": "转接成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) End(c *gin.Context) {
|
func (h *SessionHandler) End(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
reason := c.Query("reason")
|
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{}).
|
result := model.DB.Model(&model.Session{}).
|
||||||
Where("id = ? AND tenant_id = ?", id, tenantID).
|
Where("id = ? AND tenant_id = ? AND status <> ?", session.ID, session.TenantID, "ended").
|
||||||
Updates(map[string]interface{}{"status": "ended", "end_reason": reason})
|
Updates(map[string]interface{}{"status": "ended", "end_reason": reason, "ended_at": now})
|
||||||
|
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
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{
|
model.DB.Create(&model.SessionEvent{
|
||||||
SessionID: parseID(id),
|
SessionID: session.ID,
|
||||||
OperatorID: middleware.GetUserID(c),
|
OperatorID: middleware.GetUserID(c),
|
||||||
Action: "end",
|
Action: "end",
|
||||||
Detail: "结束会话: " + reason,
|
Detail: "结束会话: " + reason,
|
||||||
})
|
})
|
||||||
|
session.Status = "ended"
|
||||||
|
session.EndedAt = &now
|
||||||
|
broadcastSessionUpdate(session)
|
||||||
|
|
||||||
middleware.JSON(c, gin.H{"message": "已结束"})
|
middleware.JSON(c, gin.H{"message": "已结束"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) UpdatePriority(c *gin.Context) {
|
func (h *SessionHandler) UpdatePriority(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
priority := c.Query("priority")
|
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{}).
|
result := model.DB.Model(&model.Session{}).
|
||||||
Where("id = ? AND tenant_id = ?", id, tenantID).
|
Where("id = ? AND tenant_id = ?", session.ID, session.TenantID).
|
||||||
Update("priority", priority)
|
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": "已更新"})
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"kefu-sys/server/internal/middleware"
|
"kefu-sys/server/internal/middleware"
|
||||||
"kefu-sys/server/internal/model"
|
"kefu-sys/server/internal/model"
|
||||||
@@ -10,69 +14,308 @@ type StatisticsHandler struct{}
|
|||||||
|
|
||||||
func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} }
|
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) {
|
func (h *StatisticsHandler) KPIs(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
if !requireStatisticsAccess(c) {
|
||||||
|
return
|
||||||
var totalSessions, totalMessages int64
|
}
|
||||||
var avgResponseTime float64
|
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
||||||
var satisfactionAvg float64
|
if err != nil {
|
||||||
var firstResolveRate float64
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"})
|
||||||
|
return
|
||||||
model.DB.Model(&model.Session{}).Where("tenant_id = ?", tenantID).Count(&totalSessions)
|
}
|
||||||
model.DB.Model(&model.Message{}).
|
responses := firstResponseSeconds(messages)
|
||||||
Joins("JOIN sessions ON messages.session_id = sessions.id").
|
var responseTotal float64
|
||||||
Where("sessions.tenant_id = ?", tenantID).
|
for _, seconds := range responses {
|
||||||
Count(&totalMessages)
|
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{
|
middleware.JSON(c, gin.H{
|
||||||
"total_sessions": totalSessions,
|
"total_sessions": len(sessions),
|
||||||
"avg_response_time": avgResponseTime,
|
"avg_response_time": avgResponse,
|
||||||
"satisfaction_avg": satisfactionAvg,
|
"satisfaction_avg": satisfactionAvg,
|
||||||
"first_resolve_rate": firstResolveRate,
|
"first_resolve_rate": firstResolveRate(sessions),
|
||||||
"total_messages": totalMessages,
|
"total_messages": len(messages),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
||||||
tenantID := middleware.GetTenantID(c)
|
if !requireStatisticsAccess(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
period := c.DefaultQuery("period", "day")
|
period := c.DefaultQuery("period", "day")
|
||||||
|
var sessions []model.Session
|
||||||
_ = tenantID
|
if err := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)).Find(&sessions).Error; err != nil {
|
||||||
var data []gin.H
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话趋势失败"})
|
||||||
if period == "day" {
|
return
|
||||||
data = []gin.H{
|
}
|
||||||
{"date": "07-08", "count": 420}, {"date": "07-09", "count": 380},
|
now := time.Now()
|
||||||
{"date": "07-10", "count": 450}, {"date": "07-11", "count": 520},
|
data := make([]gin.H, 0)
|
||||||
{"date": "07-12", "count": 490}, {"date": "07-13", "count": 550},
|
if period == "today" {
|
||||||
{"date": "07-14", "count": 610},
|
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 {
|
} else {
|
||||||
data = []gin.H{
|
counts := make(map[string]int)
|
||||||
{"date": "06", "count": 12500}, {"date": "07", "count": 13800},
|
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)
|
middleware.JSON(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
|
func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
|
||||||
|
if !requireStatisticsAccess(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
tenantID := middleware.GetTenantID(c)
|
tenantID := middleware.GetTenantID(c)
|
||||||
_ = tenantID
|
sessions, messages, err := loadStatisticsData(tenantID)
|
||||||
|
if err != nil {
|
||||||
data := []gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
|
||||||
{"name": "客服小王", "conversations": 420, "avg_response": 28, "satisfaction": 4.9},
|
return
|
||||||
{"name": "客服小李", "conversations": 380, "avg_response": 35, "satisfaction": 4.7},
|
}
|
||||||
{"name": "客服小张", "conversations": 350, "avg_response": 42, "satisfaction": 4.5},
|
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)
|
middleware.JSON(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
||||||
data := []gin.H{
|
if !requireStatisticsAccess(c) {
|
||||||
{"type": "网页", "value": 45}, {"type": "微信", "value": 28},
|
return
|
||||||
{"type": "APP", "value": 18}, {"type": "电话", "value": 6}, {"type": "邮件", "value": 3},
|
}
|
||||||
|
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)
|
middleware.JSON(c, data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kefu-sys/server/internal/middleware"
|
||||||
"kefu-sys/server/internal/model"
|
"kefu-sys/server/internal/model"
|
||||||
|
"kefu-sys/server/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WidgetHandler struct{}
|
type WidgetHandler struct{}
|
||||||
@@ -21,25 +28,44 @@ type WidgetMessageReq struct {
|
|||||||
SessionID uint `json:"session_id" binding:"required"`
|
SessionID uint `json:"session_id" binding:"required"`
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
Type string `json:"type"`
|
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) {
|
func (h *WidgetHandler) Init(c *gin.Context) {
|
||||||
var req WidgetInitReq
|
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": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
return
|
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 == "" {
|
if req.ChannelKey == "" {
|
||||||
req.ChannelKey = c.Query("channel_key")
|
req.ChannelKey = c.Query("channel_key")
|
||||||
}
|
}
|
||||||
|
if !channelKeyPattern.MatchString(req.ChannelKey) {
|
||||||
var channel model.Channel
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道标识无效"})
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var channel model.Channel
|
||||||
|
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
|
name := req.VisitorName
|
||||||
@@ -60,15 +86,28 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
model.DB.Create(&customer)
|
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{
|
session := model.Session{
|
||||||
TenantID: channel.TenantID,
|
TenantID: channel.TenantID,
|
||||||
ChannelID: channel.ID,
|
ChannelID: channel.ID,
|
||||||
CustomerID: customer.ID,
|
CustomerID: customer.ID,
|
||||||
|
VisitorTokenHash: visitorTokenHash,
|
||||||
Status: "waiting",
|
Status: "waiting",
|
||||||
Priority: "normal",
|
Priority: "normal",
|
||||||
}
|
}
|
||||||
model.DB.Create(&session)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@@ -77,10 +116,31 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
"customer_id": customer.ID,
|
"customer_id": customer.ID,
|
||||||
"channel_id": channel.ID,
|
"channel_id": channel.ID,
|
||||||
"tenant_id": channel.TenantID,
|
"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) {
|
func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||||
var req WidgetMessageReq
|
var req WidgetMessageReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -91,19 +151,23 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
|||||||
req.Type = "text"
|
req.Type = "text"
|
||||||
}
|
}
|
||||||
|
|
||||||
var session model.Session
|
session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
|
||||||
if err := model.DB.First(&session, req.SessionID).Error; err != nil {
|
if !ok {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
return
|
||||||
|
}
|
||||||
|
if session.Status == "ended" || session.Status == "archived" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是等待中的会话,更新为活跃
|
// 如果是等待中的会话,更新为活跃
|
||||||
if session.Status == "waiting" {
|
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
|
custID := session.CustomerID
|
||||||
msg := model.Message{
|
msg := model.Message{
|
||||||
@@ -112,23 +176,110 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
|||||||
SenderID: &custID,
|
SenderID: &custID,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Seq: maxSeq + 1,
|
|
||||||
SentAt: time.Now(),
|
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": "发送失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||||
return
|
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})
|
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) {
|
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
|
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})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": messages})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ func NewWsHandler() *WsHandler { return &WsHandler{} }
|
|||||||
func (h *WsHandler) Connect(c *gin.Context) {
|
func (h *WsHandler) Connect(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
tenantID := middleware.GetTenantID(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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"kefu-sys/server/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
var jwtSecret []byte
|
var jwtSecret []byte
|
||||||
@@ -37,26 +40,91 @@ func GenerateToken(userID, tenantID uint, role string) (string, error) {
|
|||||||
return token.SignedString(jwtSecret)
|
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 {
|
func AuthRequired() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
auth := c.GetHeader("Authorization")
|
tokenStr := tokenFromRequest(c)
|
||||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
if tokenStr == "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未授权"})
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未授权"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
claims, err := ParseToken(tokenStr)
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
if err != nil {
|
||||||
return jwtSecret, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "token无效"})
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "token无效"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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("user_id", claims.UserID)
|
||||||
c.Set("tenant_id", claims.TenantID)
|
c.Set("tenant_id", claims.TenantID)
|
||||||
c.Set("role", claims.Role)
|
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 {
|
func AdminRequired() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
role, _ := c.Get("role")
|
if !HasAnyRole(c, "admin", "platform_admin") {
|
||||||
if role != "admin" && role != "platform_admin" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权限"})
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权限"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -78,8 +161,7 @@ func AdminRequired() gin.HandlerFunc {
|
|||||||
|
|
||||||
func PlatformRequired() gin.HandlerFunc {
|
func PlatformRequired() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
role, _ := c.Get("role")
|
if !HasAnyRole(c, "platform_admin") {
|
||||||
if role != "platform_admin" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅平台管理员可操作"})
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅平台管理员可操作"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -111,16 +193,25 @@ func GetPageParams(c *gin.Context) (page, pageSize int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func JSON(c *gin.Context, data interface{}) {
|
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) {
|
func JSONList(c *gin.Context, list interface{}, total int64, page, pageSize int) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
"list": list,
|
"list": nonNilSlice(list),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"pageSize": pageSize,
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,16 @@ func InitDB(dsn string) {
|
|||||||
log.Fatalf("数据库连接失败: %v", err)
|
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{},
|
&Tenant{},
|
||||||
&User{},
|
&User{},
|
||||||
&Channel{},
|
&Channel{},
|
||||||
@@ -33,9 +42,4 @@ func InitDB(dsn string) {
|
|||||||
&OperationLog{},
|
&OperationLog{},
|
||||||
&Announcement{},
|
&Announcement{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("数据库迁移完成")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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("重复会话消息序号未被唯一索引拦截")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ type Session struct {
|
|||||||
ChannelID uint `json:"channel_id"`
|
ChannelID uint `json:"channel_id"`
|
||||||
CustomerID uint `gorm:"index" json:"customer_id"`
|
CustomerID uint `gorm:"index" json:"customer_id"`
|
||||||
AgentID *uint `gorm:"index" json:"agent_id"`
|
AgentID *uint `gorm:"index" json:"agent_id"`
|
||||||
|
VisitorTokenHash string `gorm:"size:64;index" json:"-"`
|
||||||
Status string `gorm:"size:20;default:waiting" json:"status"`
|
Status string `gorm:"size:20;default:waiting" json:"status"`
|
||||||
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
||||||
SatisfactionScore *int `json:"satisfaction_score"`
|
SatisfactionScore *int `json:"satisfaction_score"`
|
||||||
@@ -81,12 +82,12 @@ type Session struct {
|
|||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
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"`
|
SenderType string `gorm:"size:20;not null" json:"sender_type"`
|
||||||
SenderID *uint `json:"sender_id"`
|
SenderID *uint `json:"sender_id"`
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
Type string `gorm:"size:20;default:text" json:"type"`
|
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"`
|
SentAt time.Time `json:"sent_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+86
-56
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
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 {
|
type Client struct {
|
||||||
@@ -19,23 +20,20 @@ type Client struct {
|
|||||||
UserID uint
|
UserID uint
|
||||||
TenantID uint
|
TenantID uint
|
||||||
Role string
|
Role string
|
||||||
|
Kind string
|
||||||
|
SessionID *uint
|
||||||
Send chan []byte
|
Send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Event struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
SessionID uint `json:"session_id"`
|
SessionID uint `json:"session_id,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Data interface{} `json:"data,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"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
clients map[*Client]bool
|
clients map[*Client]bool
|
||||||
broadcast chan []byte
|
|
||||||
register chan *Client
|
register chan *Client
|
||||||
unregister chan *Client
|
unregister chan *Client
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -46,12 +44,20 @@ var DefaultHub = NewHub()
|
|||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
clients: make(map[*Client]bool),
|
clients: make(map[*Client]bool),
|
||||||
broadcast: make(chan []byte, 256),
|
|
||||||
register: make(chan *Client),
|
register: make(chan *Client),
|
||||||
unregister: 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() {
|
func (h *Hub) Run() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -67,93 +73,117 @@ func (h *Hub) Run() {
|
|||||||
close(client.Send)
|
close(client.Send)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case msg := <-h.broadcast:
|
func (h *Hub) send(client *Client, message []byte) {
|
||||||
h.mu.RLock()
|
|
||||||
for client := range h.clients {
|
|
||||||
select {
|
select {
|
||||||
case client.Send <- msg:
|
case client.Send <- message:
|
||||||
default:
|
default:
|
||||||
close(client.Send)
|
|
||||||
delete(h.clients, client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.RUnlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) BroadcastToTenant(tenantID uint, msg []byte) {
|
// BroadcastToSession 仅把消息推送给当前客服、主管/管理员及该会话的访客。
|
||||||
|
func (h *Hub) BroadcastToSession(tenantID, sessionID uint, agentID *uint, message []byte) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
if client.TenantID == tenantID {
|
if client.TenantID != tenantID {
|
||||||
select {
|
continue
|
||||||
case client.Send <- msg:
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
|
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 HandleWebSocket(c *Client) {
|
// BroadcastToTenantStaff 只通知租户内的工作人员,不向访客泄露其他会话事件。
|
||||||
conn := c.Conn
|
func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
go func() {
|
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)
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg, ok := <-c.Send:
|
case message, ok := <-client.Send:
|
||||||
if !ok {
|
if !ok {
|
||||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
client.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
client.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
if err := client.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
client.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
_, msgBytes, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg Message
|
func upgrade(w http.ResponseWriter, r *http.Request, client *Client) (*Client, error) {
|
||||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg.TenantID = c.TenantID
|
|
||||||
msg.FromID = c.UserID
|
|
||||||
msg.Timestamp = time.Now().UnixMilli()
|
|
||||||
|
|
||||||
reply, _ := json.Marshal(msg)
|
|
||||||
DefaultHub.BroadcastToTenant(c.TenantID, reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Upgrade(w http.ResponseWriter, r *http.Request, userID, tenantID uint, role string) (*Client, error) {
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
client := &Client{
|
||||||
Conn: conn,
|
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
Role: role,
|
Role: role,
|
||||||
|
Kind: "agent",
|
||||||
Send: make(chan []byte, 256),
|
Send: make(chan []byte, 256),
|
||||||
}
|
}
|
||||||
DefaultHub.register <- client
|
|
||||||
log.Printf("WebSocket 连接: user=%d tenant=%d", userID, tenantID)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,32 @@ const RequireAuth = () => {
|
|||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RequirePlatformAdmin = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (!user) return <Navigate to="/login" replace />
|
||||||
|
if (user.role !== 'platform_admin') return <Navigate to="/agent/dashboard" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequireStaff = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (!user) return <Navigate to="/login" replace />
|
||||||
|
if (user.role === 'platform_admin') return <Navigate to="/admin/dashboard" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequireSupervisor = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (!user) return <Navigate to="/login" replace />
|
||||||
|
if (user.role !== 'admin' && user.role !== 'supervisor') return <Navigate to="/agent/dashboard" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequireTenantAdmin = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (!user) return <Navigate to="/login" replace />
|
||||||
|
if (user.role !== 'admin') return <Navigate to="/agent/dashboard" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
export default RequireAuth
|
export default RequireAuth
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ const AgentSidebar = () => {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -38,7 +43,7 @@ const AgentSidebar = () => {
|
|||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
items={menuItems}
|
items={visibleMenuItems}
|
||||||
onClick={({ key }) => navigate(key)}
|
onClick={({ key }) => navigate(key)}
|
||||||
className="border-e-0 mt-2 flex-1"
|
className="border-e-0 mt-2 flex-1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ const Login = () => {
|
|||||||
const onFinish = async (values: { username: string; password: string }) => {
|
const onFinish = async (values: { username: string; password: string }) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await login(values.username, values.password)
|
const user = await login(values.username, values.password)
|
||||||
message.success('登录成功')
|
message.success('登录成功')
|
||||||
navigate('/agent/dashboard', { replace: true })
|
navigate(user.role === 'platform_admin' ? '/admin/dashboard' : '/agent/dashboard', { replace: true })
|
||||||
} catch {
|
} catch {
|
||||||
message.error('用户名或密码错误')
|
message.error('用户名或密码错误')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Input, Select, Tag, Empty, Spin } from 'antd'
|
import { Input, Select, Tag, Empty, Spin } from 'antd'
|
||||||
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
|
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<string, string> = { active: 'blue', ended: 'green', archived: 'default' }
|
const statusColors: Record<string, string> = { active: 'blue', ended: 'green', archived: 'default' }
|
||||||
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', waiting: '等待中' }
|
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', waiting: '等待中' }
|
||||||
@@ -12,6 +12,8 @@ const ChatHistory = () => {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<string>()
|
const [statusFilter, setStatusFilter] = useState<string>()
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
|
const [messages, setMessages] = useState<{ id: number; sender_type: string; content: string; sent_at: string }[]>([])
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => { loadSessions() }, [statusFilter])
|
useEffect(() => { loadSessions() }, [statusFilter])
|
||||||
|
|
||||||
@@ -26,6 +28,18 @@ const ChatHistory = () => {
|
|||||||
|
|
||||||
const selected = sessions.find(s => s.id === selectedId)
|
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 (
|
return (
|
||||||
<div className="h-full flex">
|
<div className="h-full flex">
|
||||||
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
|
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
|
||||||
@@ -74,7 +88,15 @@ const ChatHistory = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm text-neutral-400 text-center">消息内容通过 WebSocket 实时传输</div>
|
{detailLoading ? <div className="flex justify-center py-10"><Spin /></div> : messages.length === 0 ? <Empty description="暂无消息记录" /> : messages.map(message => (
|
||||||
|
<div key={message.id} className={`flex ${message.sender_type === 'agent' ? 'justify-start' : 'justify-end'}`}>
|
||||||
|
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-sm ${message.sender_type === 'agent' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||||
|
<div className="text-xs mb-1 opacity-70">{message.sender_type === 'agent' ? '客服' : '访客'}</div>
|
||||||
|
<div>{message.content}</div>
|
||||||
|
<div className="text-xs mt-1 opacity-60">{new Date(message.sent_at).toLocaleString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : <div className="h-full flex items-center justify-center text-neutral-400">选择一个对话查看详情</div>}
|
) : <div className="h-full flex items-center justify-center text-neutral-400">选择一个对话查看详情</div>}
|
||||||
|
|||||||
@@ -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 { Input, Spin, message as antMsg } from 'antd'
|
||||||
import { SearchOutlined, StarFilled } from '@ant-design/icons'
|
import { SearchOutlined, StarFilled } from '@ant-design/icons'
|
||||||
import { useAuth } from '@/stores/auth'
|
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<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' }
|
const priorityColors: Record<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' }
|
||||||
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' }
|
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' }
|
||||||
@@ -21,21 +21,21 @@ const Dashboard = () => {
|
|||||||
const initialLoad = useRef(true)
|
const initialLoad = useRef(true)
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => { loadAll() }, [])
|
const loadAll = useCallback(async () => {
|
||||||
|
|
||||||
const loadAll = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [sRes, cRes] = await Promise.all([
|
const [sRes, cRes] = await Promise.all([
|
||||||
getSessions(),
|
getSessions(),
|
||||||
getCustomers({ page: 1 }),
|
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<number, Customer> = {}
|
const map: Record<number, Customer> = {}
|
||||||
cRes.list.forEach(c => { map[c.id] = c })
|
customerList.forEach(c => { map[c.id] = c })
|
||||||
setCustomers(map)
|
setCustomers(map)
|
||||||
if (sRes.list.length > 0 && initialLoad.current) {
|
if (sessionList.length > 0 && initialLoad.current) {
|
||||||
setSelectedId(sRes.list[0].id)
|
setSelectedId(sessionList[0].id)
|
||||||
initialLoad.current = false
|
initialLoad.current = false
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -43,20 +43,16 @@ const Dashboard = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
if (selectedId) loadDetail(selectedId)
|
|
||||||
}, [selectedId])
|
|
||||||
|
|
||||||
const loadDetail = async (id: number) => {
|
|
||||||
setDetailLoading(true)
|
setDetailLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getSession(id)
|
const res = await getSession(id)
|
||||||
const msgs: any = res.data as any
|
const msgs: any = res.data as any
|
||||||
setDetail({
|
setDetail({
|
||||||
messages: (msgs.messages || []).map((m: any) => ({
|
messages: (msgs.messages || []).map((m: any) => ({
|
||||||
sender: m.sender_type === 'agent' ? '客服' : getCustomerName(id),
|
sender: m.sender_type === 'agent' ? '客服' : '访客',
|
||||||
content: m.content,
|
content: m.content,
|
||||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||||
})),
|
})),
|
||||||
@@ -67,14 +63,33 @@ const Dashboard = () => {
|
|||||||
setDetailLoading(false)
|
setDetailLoading(false)
|
||||||
}
|
}
|
||||||
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200)
|
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const getCustomerName = (sessionId: number): string => {
|
useEffect(() => { loadAll() }, [loadAll])
|
||||||
const s = sessions.find(s => s.id === sessionId)
|
|
||||||
if (!s) return '访客'
|
useEffect(() => {
|
||||||
const c = customers[s.customer_id]
|
if (!user?.token) return
|
||||||
return c ? c.name : `客户${s.customer_id}`
|
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 () => {
|
const handleSend = async () => {
|
||||||
if (!messageInput.trim() || !selectedId || sending) return
|
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 selected = sessions.find(s => s.id === selectedId)
|
||||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||||
|
|
||||||
@@ -170,8 +197,8 @@ const Dashboard = () => {
|
|||||||
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
|
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-neutral-600">转接</span>
|
<span className="text-xs text-neutral-400 cursor-not-allowed">转接</span>
|
||||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500">结束</span>
|
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500" onClick={handleEnd}>结束</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
|
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
|
||||||
|
|||||||
@@ -1,41 +1,55 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, Segmented, Row, Col } from 'antd'
|
import { Card, Segmented, Row, Col, Spin } from 'antd'
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined, ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons'
|
import { ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons'
|
||||||
import { Column, Line, Pie, Bar } from '@ant-design/charts'
|
import { Column, Line, Pie, Bar } from '@ant-design/charts'
|
||||||
|
import { getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, type StatisticsKpis } from '@/services/api'
|
||||||
const kpiData = [
|
|
||||||
{ label: '总会话量', value: '12,580', change: 12.5, icon: <MessageOutlined />, color: '#2563eb' },
|
|
||||||
{ label: '平均响应时长', value: '32s', change: -8.3, icon: <ClockCircleOutlined />, color: '#16a34a' },
|
|
||||||
{ label: '客户满意度', value: '4.8/5', change: 2.1, icon: <SmileOutlined />, color: '#d97706' },
|
|
||||||
{ label: '首次解决率', value: '86%', change: 5.7, icon: <CheckCircleOutlined />, 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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const Statistics = () => {
|
const Statistics = () => {
|
||||||
const [timeRange, setTimeRange] = useState<string>('week')
|
const [timeRange, setTimeRange] = useState<'today' | 'week' | 'month'>('week')
|
||||||
|
const [kpis, setKpis] = useState<StatisticsKpis | null>(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: <MessageOutlined />, color: '#2563eb' },
|
||||||
|
{ label: '平均响应时长', value: `${Math.round(kpis?.avg_response_time ?? 0)}s`, icon: <ClockCircleOutlined />, color: '#16a34a' },
|
||||||
|
{ label: '客户满意度', value: `${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`, icon: <SmileOutlined />, color: '#d97706' },
|
||||||
|
{ label: '首次解决率', value: `${(kpis?.first_resolve_rate ?? 0).toFixed(1)}%`, icon: <CheckCircleOutlined />, color: '#0891b2' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6">
|
<div className="h-full overflow-auto p-6">
|
||||||
@@ -43,7 +57,7 @@ const Statistics = () => {
|
|||||||
<h2 className="text-lg font-semibold text-neutral-800">数据统计</h2>
|
<h2 className="text-lg font-semibold text-neutral-800">数据统计</h2>
|
||||||
<Segmented
|
<Segmented
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={v => setTimeRange(v as string)}
|
onChange={v => setTimeRange(v as 'today' | 'week' | 'month')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'today', label: '今日' },
|
{ value: 'today', label: '今日' },
|
||||||
{ value: 'week', label: '本周' },
|
{ value: 'week', label: '本周' },
|
||||||
@@ -52,87 +66,49 @@ const Statistics = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI 卡片 */}
|
{loading && !kpis ? <div className="h-64 flex items-center justify-center"><Spin size="large" /></div> : <>
|
||||||
<Row gutter={[16, 16]} className="mb-6">
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
{kpiData.map((kpi, i) => (
|
{kpiData.map((kpi, i) => (
|
||||||
<Col key={i} xs={24} sm={12} lg={6}>
|
<Col key={i} xs={24} sm={12} lg={6}>
|
||||||
<Card className="!rounded-lg" bordered={false}>
|
<Card className="!rounded-lg" bordered={false} loading={loading}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm text-neutral-400">{kpi.label}</span>
|
<span className="text-sm text-neutral-400">{kpi.label}</span>
|
||||||
<span className="text-lg" style={{ color: kpi.color }}>{kpi.icon}</span>
|
<span className="text-lg" style={{ color: kpi.color }}>{kpi.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-neutral-800 mb-1">{kpi.value}</div>
|
<div className="text-2xl font-bold text-neutral-800 mb-1">{kpi.value}</div>
|
||||||
<div className={`text-xs flex items-center gap-1 ${kpi.change >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
<div className="text-xs text-neutral-400">基于已记录会话实时计算</div>
|
||||||
{kpi.change >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
|
||||||
<span>{Math.abs(kpi.change)}% 较上期</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 图表区 */}
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="会话量趋势" className="!rounded-lg" bordered={false}>
|
<Card title="会话量趋势" className="!rounded-lg" bordered={false}>
|
||||||
<Line
|
<Line data={sessionTrendData} xField="date" yField="count" smooth height={260} color="#2563eb" point={{ size: 3 }} tooltip={{ channel: 'y' }} axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }} />
|
||||||
data={sessionTrendData}
|
|
||||||
xField="date"
|
|
||||||
yField="count"
|
|
||||||
smooth
|
|
||||||
height={260}
|
|
||||||
color="#2563eb"
|
|
||||||
point={{ size: 3 }}
|
|
||||||
tooltip={{ channel: 'y' }}
|
|
||||||
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="响应时长分布" className="!rounded-lg" bordered={false}>
|
<Card title="响应时长分布" className="!rounded-lg" bordered={false}>
|
||||||
<Column
|
<Column data={responseDistribution} xField="range" yField="count" height={260} color="#0891b2" tooltip={{ channel: 'y' }} axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }} />
|
||||||
data={responseDistribution}
|
|
||||||
xField="range"
|
|
||||||
yField="count"
|
|
||||||
height={260}
|
|
||||||
color="#0891b2"
|
|
||||||
tooltip={{ channel: 'y' }}
|
|
||||||
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="渠道来源占比" className="!rounded-lg" bordered={false}>
|
<Card title="渠道来源占比" className="!rounded-lg" bordered={false}>
|
||||||
<Pie
|
<Pie data={channelData} angleField="value" colorField="type" height={260} radius={0.8} innerRadius={0.5} label={{ text: 'type', position: 'outside' }} legend={{ color: { position: 'bottom' } }} />
|
||||||
data={channelData}
|
|
||||||
angleField="value"
|
|
||||||
colorField="type"
|
|
||||||
height={260}
|
|
||||||
radius={0.8}
|
|
||||||
innerRadius={0.5}
|
|
||||||
label={{ text: 'type', position: 'outside' }}
|
|
||||||
legend={{ color: { position: 'bottom' } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="客服绩效排行" className="!rounded-lg" bordered={false}>
|
<Card title="客服绩效排行" className="!rounded-lg" bordered={false}>
|
||||||
<Bar
|
<Bar data={agentPerformance} xField="conversations" yField="name" height={260} color="#2563eb" tooltip={{ items: [
|
||||||
data={agentPerformance}
|
|
||||||
xField="conversations"
|
|
||||||
yField="name"
|
|
||||||
height={260}
|
|
||||||
color="#2563eb"
|
|
||||||
tooltip={{ items: [
|
|
||||||
{ channel: 'conversations', name: '接待量' },
|
{ channel: 'conversations', name: '接待量' },
|
||||||
{ channel: 'avgResponse', name: '平均响应(s)' },
|
{ channel: 'avgResponse', name: '平均响应(s)' },
|
||||||
{ channel: 'satisfaction', name: '满意度' },
|
{ channel: 'satisfaction', name: '满意度' },
|
||||||
]}}
|
]}} axis={{ x: { grid: true, gridStroke: '#f1f5f9' } }} />
|
||||||
axis={{ x: { grid: true, gridStroke: '#f1f5f9' } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense } from 'react'
|
||||||
import { Navigate, createBrowserRouter } from 'react-router-dom'
|
import { Navigate, createBrowserRouter } from 'react-router-dom'
|
||||||
import { Spin } from 'antd'
|
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 AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
|
||||||
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
|
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
|
||||||
@@ -37,6 +37,9 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
element: <RequirePlatformAdmin />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
element: <Lazy><AdminLayout /></Lazy>,
|
element: <Lazy><AdminLayout /></Lazy>,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
|
||||||
@@ -46,9 +49,14 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
|
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||||
{
|
{
|
||||||
path: 'agent',
|
path: 'agent',
|
||||||
|
element: <RequireStaff />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
element: <Lazy><AgentLayout /></Lazy>,
|
element: <Lazy><AgentLayout /></Lazy>,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||||
@@ -56,8 +64,16 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
|
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
|
||||||
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
|
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
|
||||||
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
|
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
|
||||||
{ path: 'statistics', element: <Lazy><Statistics /></Lazy> },
|
{
|
||||||
{ path: 'settings', element: <Lazy><Settings /></Lazy> },
|
element: <RequireSupervisor />,
|
||||||
|
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <RequireTenantAdmin />,
|
||||||
|
children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+13
-2
@@ -23,6 +23,14 @@ export interface Tenant {
|
|||||||
contact_name: string; contact_phone: string; contact_email: string
|
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
|
// Auth
|
||||||
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
||||||
|
|
||||||
@@ -58,8 +66,11 @@ export const getKnowledgeEntries = (params?: { category_id?: string; search?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
export const getKPIs = () => get<Record<string, number>>('/statistics/kpi')
|
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
||||||
export const getSessionTrend = () => get<{ date: string; count: number }[]>('/statistics/trend')
|
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
|
// Admin
|
||||||
export const getTenants = (params?: { search?: string; status?: string; page?: number }) => {
|
export const getTenants = (params?: { search?: string; status?: string; page?: number }) => {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { login as loginApi, type LoginResult } from '@/services/api'
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: LoginResult | null
|
user: LoginResult | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<LoginResult>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState>({
|
const AuthContext = createContext<AuthState>({
|
||||||
user: null, loading: false,
|
user: null, loading: false,
|
||||||
login: async () => {}, logout: () => {},
|
login: async () => { throw new Error('认证上下文未初始化') }, logout: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -34,6 +34,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(u.token)
|
setToken(u.token)
|
||||||
setUser(u)
|
setUser(u)
|
||||||
localStorage.setItem('auth_user', JSON.stringify(u))
|
localStorage.setItem('auth_user', JSON.stringify(u))
|
||||||
|
return u
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled } from '@ant-design/icons'
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -10,6 +10,7 @@ interface Message {
|
|||||||
|
|
||||||
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||||
const STORAGE_KEY = 'kefu_widget_session'
|
const STORAGE_KEY = 'kefu_widget_session'
|
||||||
|
const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token'
|
||||||
|
|
||||||
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
@@ -17,6 +18,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
return saved ? Number(saved) : null
|
return saved ? Number(saved) : null
|
||||||
})
|
})
|
||||||
|
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(VISITOR_TOKEN_KEY) || '')
|
||||||
const [messages, setMessages] = useState<Message[]>(() => {
|
const [messages, setMessages] = useState<Message[]>(() => {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY + '_msgs')
|
const saved = localStorage.getItem(STORAGE_KEY + '_msgs')
|
||||||
return saved ? JSON.parse(saved) : []
|
return saved ? JSON.parse(saved) : []
|
||||||
@@ -25,31 +27,19 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [showRating, setShowRating] = useState(false)
|
const [showRating, setShowRating] = useState(false)
|
||||||
const [rated, setRated] = useState(false)
|
const [rated, setRated] = useState(false)
|
||||||
|
const [sessionEnded, setSessionEnded] = useState(false)
|
||||||
|
const [ratingText, setRatingText] = useState('')
|
||||||
const pollRef = useRef<number | null>(null)
|
const pollRef = useRef<number | null>(null)
|
||||||
const initRef = useRef(false)
|
const initRef = useRef(false)
|
||||||
|
|
||||||
const initSession = async () => {
|
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||||
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 s = sid || sessionId
|
const s = sid || sessionId
|
||||||
if (!s) return
|
const visitorCredential = token || visitorToken || localStorage.getItem(VISITOR_TOKEN_KEY) || ''
|
||||||
|
if (!s || !visitorCredential) return
|
||||||
try {
|
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()
|
const json = await res.json()
|
||||||
if (json.code === 0 && json.data && json.data.length > 0) {
|
if (json.code === 0 && json.data && json.data.length > 0) {
|
||||||
const msgs: Message[] = json.data.map((m: any) => ({
|
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))
|
localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs))
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (open && !initRef.current) {
|
if (open) {
|
||||||
initSession()
|
initSession()
|
||||||
}
|
}
|
||||||
}, [open, sessionId])
|
}, [open, initSession])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId && open) {
|
if (sessionId && visitorToken && open) {
|
||||||
pollRef.current = window.setInterval(() => loadMessages(), 3000)
|
pollRef.current = window.setInterval(() => loadMessages(), 3000)
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
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) => {
|
const sendMessage = async (text: string) => {
|
||||||
if (!text.trim() || sending) return
|
if (!text.trim() || sending) return
|
||||||
@@ -89,11 +129,11 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
}
|
}
|
||||||
setMessages(prev => [...prev, localMsg])
|
setMessages(prev => [...prev, localMsg])
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId && visitorToken) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/widget/message', {
|
await fetch('/api/widget/message', {
|
||||||
method: 'POST',
|
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' }),
|
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
||||||
})
|
})
|
||||||
loadMessages(sessionId)
|
loadMessages(sessionId)
|
||||||
@@ -104,13 +144,30 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
setShowRating(false)
|
setShowRating(sessionEnded && !rated)
|
||||||
setRated(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false)
|
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 (
|
return (
|
||||||
@@ -173,9 +230,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||||
disabled={sending || !sessionId}
|
disabled={sending || !sessionId || sessionEnded}
|
||||||
/>
|
/>
|
||||||
<SendOutlined className={`cursor-pointer ${sessionId ? 'text-blue-500 hover:text-blue-600' : 'text-neutral-300'}`} onClick={() => sendMessage(input)} />
|
<SendOutlined className={`cursor-pointer ${sessionId && !sessionEnded ? 'text-blue-500 hover:text-blue-600' : 'text-neutral-300'}`} onClick={() => sendMessage(input)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,9 +244,17 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
<div className="flex justify-center gap-1.5 mb-4">
|
<div className="flex justify-center gap-1.5 mb-4">
|
||||||
{[1, 2, 3, 4, 5].map(star => (
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
<StarFilled key={star} className="text-2xl cursor-pointer text-neutral-200 hover:text-yellow-400 transition-colors"
|
<StarFilled key={star} className="text-2xl cursor-pointer text-neutral-200 hover:text-yellow-400 transition-colors"
|
||||||
onClick={() => { setRated(true); setShowRating(false) }} />
|
onClick={() => submitRating(star)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
value={ratingText}
|
||||||
|
onChange={e => setRatingText(e.target.value)}
|
||||||
|
placeholder="可选:写下您的服务感受"
|
||||||
|
/>
|
||||||
<button onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
<button onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user