修复会话安全与实时消息
This commit is contained in:
@@ -4,9 +4,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthHandler struct{}
|
||||
@@ -18,14 +18,6 @@ type LoginReq struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Nickname string `json:"nickname" binding:"required"`
|
||||
TenantID uint `json:"tenant_id" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -44,8 +36,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status == "disabled" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "账号已被禁用"})
|
||||
if status, message := middleware.ValidateUserAccess(user.ID, user.TenantID, user.Role); status != 0 {
|
||||
c.JSON(status, gin.H{"code": status, "message": message})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,37 +59,3 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req RegisterReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role == "" {
|
||||
req.Role = "agent"
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
Nickname: req.Nickname,
|
||||
TenantID: req.TenantID,
|
||||
Role: req.Role,
|
||||
Status: "online",
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "用户名已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "注册成功"})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ type CustomerHandler struct{}
|
||||
|
||||
func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} }
|
||||
|
||||
func canAccessCustomer(c *gin.Context, customerID uint) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
return true
|
||||
}
|
||||
if middleware.GetRole(c) != "agent" {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
model.DB.Model(&model.Session{}).
|
||||
Where("tenant_id = ? AND customer_id = ? AND agent_id = ?", middleware.GetTenantID(c), customerID, middleware.GetUserID(c)).
|
||||
Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) List(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
page, pageSize := middleware.GetPageParams(c)
|
||||
@@ -23,6 +37,12 @@ func (h *CustomerHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
assignedCustomers := model.DB.Model(&model.Session{}).
|
||||
Select("customer_id").
|
||||
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c))
|
||||
query = query.Where("id IN (?)", assignedCustomers)
|
||||
}
|
||||
if search != "" {
|
||||
query = query.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
@@ -48,10 +68,17 @@ func (h *CustomerHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"})
|
||||
return
|
||||
}
|
||||
if !canAccessCustomer(c, customer.ID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"})
|
||||
return
|
||||
}
|
||||
|
||||
var sessions []model.Session
|
||||
model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID).
|
||||
Order("created_at desc").Limit(20).Find(&sessions)
|
||||
sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
sessionQuery.Order("created_at desc").Limit(20).Find(&sessions)
|
||||
|
||||
middleware.JSON(c, gin.H{"customer": customer, "sessions": sessions})
|
||||
}
|
||||
@@ -82,6 +109,10 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"})
|
||||
return
|
||||
}
|
||||
if !canAccessCustomer(c, customer.ID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"})
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
@@ -89,15 +120,30 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 不允许修改 tenant_id
|
||||
delete(updates, "tenant_id")
|
||||
delete(updates, "id")
|
||||
allowed := map[string]bool{"name": true, "phone": true, "email": true, "tags": true, "source": true, "status": true}
|
||||
for key := range updates {
|
||||
if !allowed[key] {
|
||||
delete(updates, key)
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Model(&customer).Updates(updates)
|
||||
if err := model.DB.Model(&customer).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
model.DB.First(&customer, customer.ID)
|
||||
middleware.JSON(c, customer)
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Delete(c *gin.Context) {
|
||||
if !middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
|
||||
@@ -12,6 +12,36 @@ type KnowledgeHandler struct{}
|
||||
|
||||
func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} }
|
||||
|
||||
func requireKnowledgeManager(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理知识库"})
|
||||
return false
|
||||
}
|
||||
|
||||
func hasKnowledgeCapacity(tenantID uint) (bool, error) {
|
||||
var tenant model.Tenant
|
||||
if err := model.DB.First(&tenant, tenantID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if tenant.PlanID == nil {
|
||||
return true, nil
|
||||
}
|
||||
var plan model.Plan
|
||||
if err := model.DB.First(&plan, *tenant.PlanID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if plan.KBLimit == 0 {
|
||||
return true, nil
|
||||
}
|
||||
var count int64
|
||||
if err := model.DB.Model(&model.KnowledgeEntry{}).Where("tenant_id = ?", tenantID).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count < int64(plan.KBLimit), nil
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) ListCategories(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
|
||||
@@ -22,6 +52,9 @@ func (h *KnowledgeHandler) ListCategories(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
return
|
||||
}
|
||||
var category model.Category
|
||||
if err := c.ShouldBindJSON(&category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
@@ -65,12 +98,29 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
return
|
||||
}
|
||||
var entry model.KnowledgeEntry
|
||||
if err := c.ShouldBindJSON(&entry); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
entry.TenantID = middleware.GetTenantID(c)
|
||||
available, err := hasKnowledgeCapacity(entry.TenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "校验知识库容量失败"})
|
||||
return
|
||||
}
|
||||
if !available {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "当前套餐知识库容量已达上限"})
|
||||
return
|
||||
}
|
||||
var category model.Category
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", entry.CategoryID, entry.TenantID).First(&category).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "知识分类不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&entry).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
@@ -81,6 +131,9 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -96,14 +149,36 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
delete(updates, "tenant_id")
|
||||
delete(updates, "id")
|
||||
allowed := map[string]bool{"category_id": true, "title": true, "content": true, "status": true}
|
||||
for key := range updates {
|
||||
if !allowed[key] {
|
||||
delete(updates, key)
|
||||
}
|
||||
}
|
||||
if categoryID, exists := updates["category_id"]; exists {
|
||||
var category model.Category
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", categoryID, tenantID).First(&category).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "知识分类不存在"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Model(&entry).Updates(updates)
|
||||
if err := model.DB.Model(&entry).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
model.DB.First(&entry, entry.ID)
|
||||
middleware.JSON(c, entry)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ func SetupRoutes(r *gin.Engine) {
|
||||
|
||||
// 公开接口
|
||||
api.POST("/login", auth.Login)
|
||||
api.POST("/register", auth.Register)
|
||||
|
||||
// widget 接口
|
||||
widgetApi := api.Group("/widget")
|
||||
@@ -27,6 +26,8 @@ func SetupRoutes(r *gin.Engine) {
|
||||
widgetApi.GET("/init", widget.Init)
|
||||
widgetApi.POST("/message", widget.SendMessage)
|
||||
widgetApi.GET("/messages", widget.GetMessages)
|
||||
widgetApi.GET("/ws", widget.Connect)
|
||||
widgetApi.POST("/rating", widget.SubmitRating)
|
||||
|
||||
// 需要认证的接口
|
||||
authRequired := api.Group("")
|
||||
@@ -67,6 +68,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
statistics := authRequired.Group("/statistics")
|
||||
statistics.GET("/kpi", stats.KPIs)
|
||||
statistics.GET("/trend", stats.SessionTrend)
|
||||
statistics.GET("/response-distribution", stats.ResponseDistribution)
|
||||
statistics.GET("/performance", stats.AgentPerformance)
|
||||
statistics.GET("/channels", stats.ChannelDistribution)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
"kefu-sys/server/internal/ws"
|
||||
)
|
||||
|
||||
type SessionHandler struct{}
|
||||
@@ -19,7 +21,6 @@ type SendMessageReq struct {
|
||||
}
|
||||
|
||||
func (h *SessionHandler) SendMessage(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -32,14 +33,18 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
|
||||
req.Type = "text"
|
||||
}
|
||||
|
||||
var session model.Session
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||
session, ok := loadTenantSession(c, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canOperateSession(c, session) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权向该会话发送消息"})
|
||||
return
|
||||
}
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"})
|
||||
return
|
||||
}
|
||||
|
||||
var maxSeq int
|
||||
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq)
|
||||
|
||||
msg := model.Message{
|
||||
SessionID: session.ID,
|
||||
@@ -47,14 +52,14 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
|
||||
SenderID: &userID,
|
||||
Content: req.Content,
|
||||
Type: req.Type,
|
||||
Seq: maxSeq + 1,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&msg).Error; err != nil {
|
||||
if err := model.CreateMessage(&msg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||
return
|
||||
}
|
||||
broadcastSessionMessage(session, msg)
|
||||
|
||||
middleware.JSON(c, msg)
|
||||
}
|
||||
@@ -66,7 +71,67 @@ type CreateSessionReq struct {
|
||||
}
|
||||
|
||||
type AssignSessionReq struct {
|
||||
AgentID uint `json:"agent_id" binding:"required"`
|
||||
AgentID uint `json:"agent_id"`
|
||||
}
|
||||
|
||||
func isTenantManager(c *gin.Context) bool {
|
||||
return middleware.HasAnyRole(c, "admin", "supervisor")
|
||||
}
|
||||
|
||||
func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) {
|
||||
var session model.Session
|
||||
if err := model.DB.First(&session, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||
return nil, false
|
||||
}
|
||||
if session.TenantID != middleware.GetTenantID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权访问其他租户会话"})
|
||||
return nil, false
|
||||
}
|
||||
return &session, true
|
||||
}
|
||||
|
||||
func canReadSession(c *gin.Context, session *model.Session) bool {
|
||||
if isTenantManager(c) {
|
||||
return true
|
||||
}
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
userID := middleware.GetUserID(c)
|
||||
return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func canOperateSession(c *gin.Context, session *model.Session) bool {
|
||||
if isTenantManager(c) {
|
||||
return true
|
||||
}
|
||||
return middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c)
|
||||
}
|
||||
|
||||
func broadcastSessionMessage(session *model.Session, message model.Message) {
|
||||
payload, err := ws.NewEvent("message", session.ID, message)
|
||||
if err == nil {
|
||||
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastSessionUpdate(session *model.Session) {
|
||||
payload, err := ws.NewEvent("session_updated", session.ID, gin.H{"status": session.Status})
|
||||
if err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func loadAssignableAgent(tenantID, agentID uint) error {
|
||||
var agent model.User
|
||||
if err := model.DB.First(&agent, agentID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if agent.TenantID != tenantID || agent.Role != "agent" || agent.Status == "disabled" {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SessionHandler) List(c *gin.Context) {
|
||||
@@ -79,6 +144,9 @@ func (h *SessionHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
query = query.Where("agent_id = ? OR status = ?", middleware.GetUserID(c), "waiting")
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
@@ -93,30 +161,53 @@ func (h *SessionHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SessionHandler) Get(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var session model.Session
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||
session, ok := loadTenantSession(c, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canReadSession(c, session) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该会话"})
|
||||
return
|
||||
}
|
||||
|
||||
var messages []model.Message
|
||||
model.DB.Where("session_id = ?", session.ID).Order("seq asc").Find(&messages)
|
||||
if err := model.DB.Where("session_id = ?", session.ID).Order("seq asc").Find(&messages).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"})
|
||||
return
|
||||
}
|
||||
var events []model.SessionEvent
|
||||
model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events)
|
||||
|
||||
middleware.JSON(c, gin.H{"session": session, "messages": messages})
|
||||
middleware.JSON(c, gin.H{"session": session, "messages": messages, "events": events})
|
||||
}
|
||||
|
||||
func (h *SessionHandler) Create(c *gin.Context) {
|
||||
if !isTenantManager(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可创建会话"})
|
||||
return
|
||||
}
|
||||
var req CreateSessionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var channel model.Channel
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ? AND status = ?", req.ChannelID, tenantID, "enabled").First(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道不存在或未启用"})
|
||||
return
|
||||
}
|
||||
var customer model.Customer
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", req.CustomerID, tenantID).First(&customer).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
session := model.Session{
|
||||
TenantID: middleware.GetTenantID(c),
|
||||
TenantID: tenantID,
|
||||
ChannelID: req.ChannelID,
|
||||
CustomerID: req.CustomerID,
|
||||
Priority: req.Priority,
|
||||
@@ -136,57 +227,120 @@ func (h *SessionHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SessionHandler) Assign(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
session, ok := loadTenantSession(c, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req AssignSessionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.Status != "waiting" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"})
|
||||
return
|
||||
}
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
req.AgentID = middleware.GetUserID(c)
|
||||
} else if !isTenantManager(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权分配会话"})
|
||||
return
|
||||
}
|
||||
if req.AgentID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择目标客服"})
|
||||
return
|
||||
}
|
||||
if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"})
|
||||
return
|
||||
}
|
||||
|
||||
result := model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ? AND status = ?", id, tenantID, "waiting").
|
||||
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting").
|
||||
Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active"})
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "分配失败"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在或已被分配"})
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Create(&model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "assign", Detail: "会话分配"})
|
||||
session.AgentID = &req.AgentID
|
||||
session.Status = "active"
|
||||
broadcastSessionUpdate(session)
|
||||
middleware.JSON(c, gin.H{"message": "分配成功"})
|
||||
}
|
||||
|
||||
func (h *SessionHandler) Transfer(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
session, ok := loadTenantSession(c, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canOperateSession(c, session) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权转接该会话"})
|
||||
return
|
||||
}
|
||||
var req AssignSessionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ?", id, tenantID).
|
||||
if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"})
|
||||
return
|
||||
}
|
||||
result := model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active").
|
||||
Update("agent_id", req.AgentID)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话不可转接"})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Create(&model.SessionEvent{
|
||||
SessionID: parseID(id),
|
||||
SessionID: session.ID,
|
||||
OperatorID: middleware.GetUserID(c),
|
||||
Action: "transfer",
|
||||
Detail: "会话转接",
|
||||
})
|
||||
session.AgentID = &req.AgentID
|
||||
broadcastSessionUpdate(session)
|
||||
|
||||
middleware.JSON(c, gin.H{"message": "转接成功"})
|
||||
}
|
||||
|
||||
func (h *SessionHandler) End(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
reason := c.Query("reason")
|
||||
session, ok := loadTenantSession(c, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canOperateSession(c, session) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权结束该会话"})
|
||||
return
|
||||
}
|
||||
if reason == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
result := model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ?", id, tenantID).
|
||||
Updates(map[string]interface{}{"status": "ended", "end_reason": reason})
|
||||
Where("id = ? AND tenant_id = ? AND status <> ?", session.ID, session.TenantID, "ended").
|
||||
Updates(map[string]interface{}{"status": "ended", "end_reason": reason, "ended_at": now})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||
@@ -194,33 +348,43 @@ func (h *SessionHandler) End(c *gin.Context) {
|
||||
}
|
||||
|
||||
model.DB.Create(&model.SessionEvent{
|
||||
SessionID: parseID(id),
|
||||
SessionID: session.ID,
|
||||
OperatorID: middleware.GetUserID(c),
|
||||
Action: "end",
|
||||
Detail: "结束会话: " + reason,
|
||||
})
|
||||
session.Status = "ended"
|
||||
session.EndedAt = &now
|
||||
broadcastSessionUpdate(session)
|
||||
|
||||
middleware.JSON(c, gin.H{"message": "已结束"})
|
||||
}
|
||||
|
||||
func (h *SessionHandler) UpdatePriority(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
priority := c.Query("priority")
|
||||
session, ok := loadTenantSession(c, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canOperateSession(c, session) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权更新该会话"})
|
||||
return
|
||||
}
|
||||
if priority != "urgent" && priority != "normal" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "优先级无效"})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ?", id, tenantID).
|
||||
result := model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ?", session.ID, session.TenantID).
|
||||
Update("priority", priority)
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
session.Priority = priority
|
||||
broadcastSessionUpdate(session)
|
||||
|
||||
middleware.JSON(c, gin.H{"message": "已更新"})
|
||||
}
|
||||
|
||||
func parseID(s string) uint {
|
||||
var id uint
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
id = id*10 + uint(c-'0')
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
@@ -10,69 +14,308 @@ type StatisticsHandler struct{}
|
||||
|
||||
func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} }
|
||||
|
||||
func requireStatisticsAccess(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"})
|
||||
return false
|
||||
}
|
||||
|
||||
func loadStatisticsData(tenantID uint) ([]model.Session, []model.Message, error) {
|
||||
var sessions []model.Session
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Order("created_at asc").Find(&sessions).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
return sessions, []model.Message{}, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
ids = append(ids, session.ID)
|
||||
}
|
||||
var messages []model.Message
|
||||
if err := model.DB.Where("session_id IN ?", ids).Order("session_id asc, seq asc").Find(&messages).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return sessions, messages, nil
|
||||
}
|
||||
|
||||
func firstResponseSeconds(messages []model.Message) map[uint]float64 {
|
||||
visitorFirst := make(map[uint]time.Time)
|
||||
responseSeconds := make(map[uint]float64)
|
||||
for _, message := range messages {
|
||||
if message.SenderType == "visitor" {
|
||||
if _, exists := visitorFirst[message.SessionID]; !exists {
|
||||
visitorFirst[message.SessionID] = message.SentAt
|
||||
}
|
||||
continue
|
||||
}
|
||||
if message.SenderType != "agent" {
|
||||
continue
|
||||
}
|
||||
firstVisitorAt, hasVisitor := visitorFirst[message.SessionID]
|
||||
if !hasVisitor || !message.SentAt.After(firstVisitorAt) {
|
||||
continue
|
||||
}
|
||||
if _, exists := responseSeconds[message.SessionID]; !exists {
|
||||
responseSeconds[message.SessionID] = message.SentAt.Sub(firstVisitorAt).Seconds()
|
||||
}
|
||||
}
|
||||
return responseSeconds
|
||||
}
|
||||
|
||||
func firstResolveRate(sessions []model.Session) float64 {
|
||||
endedCount := 0
|
||||
resolvedCount := 0
|
||||
for index, session := range sessions {
|
||||
if session.Status != "ended" {
|
||||
continue
|
||||
}
|
||||
endedCount++
|
||||
if session.EndReason != "resolved" && session.EndReason != "已解决" {
|
||||
continue
|
||||
}
|
||||
endedAt := session.CreatedAt
|
||||
if session.EndedAt != nil {
|
||||
endedAt = *session.EndedAt
|
||||
}
|
||||
reopened := false
|
||||
for _, later := range sessions[index+1:] {
|
||||
if later.CustomerID == session.CustomerID && later.CreatedAt.After(endedAt) && later.CreatedAt.Sub(endedAt) <= 24*time.Hour {
|
||||
reopened = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reopened {
|
||||
resolvedCount++
|
||||
}
|
||||
}
|
||||
if endedCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(resolvedCount) * 100 / float64(endedCount)
|
||||
}
|
||||
|
||||
func (h *StatisticsHandler) KPIs(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
|
||||
var totalSessions, totalMessages int64
|
||||
var avgResponseTime float64
|
||||
var satisfactionAvg float64
|
||||
var firstResolveRate float64
|
||||
|
||||
model.DB.Model(&model.Session{}).Where("tenant_id = ?", tenantID).Count(&totalSessions)
|
||||
model.DB.Model(&model.Message{}).
|
||||
Joins("JOIN sessions ON messages.session_id = sessions.id").
|
||||
Where("sessions.tenant_id = ?", tenantID).
|
||||
Count(&totalMessages)
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"})
|
||||
return
|
||||
}
|
||||
responses := firstResponseSeconds(messages)
|
||||
var responseTotal float64
|
||||
for _, seconds := range responses {
|
||||
responseTotal += seconds
|
||||
}
|
||||
var satisfactionTotal float64
|
||||
satisfactionCount := 0
|
||||
for _, session := range sessions {
|
||||
if session.SatisfactionScore != nil {
|
||||
satisfactionTotal += float64(*session.SatisfactionScore)
|
||||
satisfactionCount++
|
||||
}
|
||||
}
|
||||
avgResponse := float64(0)
|
||||
if len(responses) > 0 {
|
||||
avgResponse = responseTotal / float64(len(responses))
|
||||
}
|
||||
satisfactionAvg := float64(0)
|
||||
if satisfactionCount > 0 {
|
||||
satisfactionAvg = satisfactionTotal / float64(satisfactionCount)
|
||||
}
|
||||
|
||||
middleware.JSON(c, gin.H{
|
||||
"total_sessions": totalSessions,
|
||||
"avg_response_time": avgResponseTime,
|
||||
"total_sessions": len(sessions),
|
||||
"avg_response_time": avgResponse,
|
||||
"satisfaction_avg": satisfactionAvg,
|
||||
"first_resolve_rate": firstResolveRate,
|
||||
"total_messages": totalMessages,
|
||||
"first_resolve_rate": firstResolveRate(sessions),
|
||||
"total_messages": len(messages),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
period := c.DefaultQuery("period", "day")
|
||||
|
||||
_ = tenantID
|
||||
var data []gin.H
|
||||
if period == "day" {
|
||||
data = []gin.H{
|
||||
{"date": "07-08", "count": 420}, {"date": "07-09", "count": 380},
|
||||
{"date": "07-10", "count": 450}, {"date": "07-11", "count": 520},
|
||||
{"date": "07-12", "count": 490}, {"date": "07-13", "count": 550},
|
||||
{"date": "07-14", "count": 610},
|
||||
var sessions []model.Session
|
||||
if err := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)).Find(&sessions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话趋势失败"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
data := make([]gin.H, 0)
|
||||
if period == "today" {
|
||||
count := 0
|
||||
for _, session := range sessions {
|
||||
if session.CreatedAt.Format("2006-01-02") == now.Format("2006-01-02") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
data = append(data, gin.H{"date": now.Format("01-02"), "count": count})
|
||||
} else if period == "month" {
|
||||
counts := make(map[string]int)
|
||||
for _, session := range sessions {
|
||||
counts[session.CreatedAt.Format("2006-01")]++
|
||||
}
|
||||
for offset := 5; offset >= 0; offset-- {
|
||||
month := time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, now.Location())
|
||||
key := month.Format("2006-01")
|
||||
data = append(data, gin.H{"date": key, "count": counts[key]})
|
||||
}
|
||||
} else {
|
||||
data = []gin.H{
|
||||
{"date": "06", "count": 12500}, {"date": "07", "count": 13800},
|
||||
counts := make(map[string]int)
|
||||
for _, session := range sessions {
|
||||
counts[session.CreatedAt.Format("01-02")]++
|
||||
}
|
||||
for offset := 6; offset >= 0; offset-- {
|
||||
day := now.AddDate(0, 0, -offset)
|
||||
key := day.Format("01-02")
|
||||
data = append(data, gin.H{"date": key, "count": counts[key]})
|
||||
}
|
||||
}
|
||||
middleware.JSON(c, data)
|
||||
}
|
||||
|
||||
func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
_, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"})
|
||||
return
|
||||
}
|
||||
counts := map[string]int{"0-10s": 0, "10-30s": 0, "30-60s": 0, "1-3min": 0, ">3min": 0}
|
||||
for _, seconds := range firstResponseSeconds(messages) {
|
||||
switch {
|
||||
case seconds <= 10:
|
||||
counts["0-10s"]++
|
||||
case seconds <= 30:
|
||||
counts["10-30s"]++
|
||||
case seconds <= 60:
|
||||
counts["30-60s"]++
|
||||
case seconds <= 180:
|
||||
counts["1-3min"]++
|
||||
default:
|
||||
counts[">3min"]++
|
||||
}
|
||||
}
|
||||
data := []gin.H{}
|
||||
for _, key := range []string{"0-10s", "10-30s", "30-60s", "1-3min", ">3min"} {
|
||||
data = append(data, gin.H{"range": key, "count": counts[key]})
|
||||
}
|
||||
middleware.JSON(c, data)
|
||||
}
|
||||
|
||||
func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
_ = tenantID
|
||||
|
||||
data := []gin.H{
|
||||
{"name": "客服小王", "conversations": 420, "avg_response": 28, "satisfaction": 4.9},
|
||||
{"name": "客服小李", "conversations": 380, "avg_response": 35, "satisfaction": 4.7},
|
||||
{"name": "客服小张", "conversations": 350, "avg_response": 42, "satisfaction": 4.5},
|
||||
sessions, messages, err := loadStatisticsData(tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
|
||||
return
|
||||
}
|
||||
var agents []model.User
|
||||
if err := model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
|
||||
return
|
||||
}
|
||||
responses := firstResponseSeconds(messages)
|
||||
type performance struct {
|
||||
ID uint
|
||||
Name string
|
||||
Conversations int
|
||||
ResponseTotal float64
|
||||
ResponseCount int
|
||||
Satisfaction float64
|
||||
RatingCount int
|
||||
}
|
||||
items := make(map[uint]*performance)
|
||||
for _, agent := range agents {
|
||||
items[agent.ID] = &performance{ID: agent.ID, Name: agent.Nickname}
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if session.AgentID == nil || items[*session.AgentID] == nil {
|
||||
continue
|
||||
}
|
||||
item := items[*session.AgentID]
|
||||
item.Conversations++
|
||||
if seconds, exists := responses[session.ID]; exists {
|
||||
item.ResponseTotal += seconds
|
||||
item.ResponseCount++
|
||||
}
|
||||
if session.SatisfactionScore != nil {
|
||||
item.Satisfaction += float64(*session.SatisfactionScore)
|
||||
item.RatingCount++
|
||||
}
|
||||
}
|
||||
values := make([]*performance, 0, len(items))
|
||||
for _, item := range items {
|
||||
values = append(values, item)
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool { return values[i].Conversations > values[j].Conversations })
|
||||
data := make([]gin.H, 0, len(values))
|
||||
for _, item := range values {
|
||||
avgResponse := float64(0)
|
||||
if item.ResponseCount > 0 {
|
||||
avgResponse = item.ResponseTotal / float64(item.ResponseCount)
|
||||
}
|
||||
satisfaction := float64(0)
|
||||
if item.RatingCount > 0 {
|
||||
satisfaction = item.Satisfaction / float64(item.RatingCount)
|
||||
}
|
||||
data = append(data, gin.H{"name": item.Name, "conversations": item.Conversations, "avg_response": avgResponse, "satisfaction": satisfaction})
|
||||
}
|
||||
|
||||
middleware.JSON(c, data)
|
||||
}
|
||||
|
||||
func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
||||
data := []gin.H{
|
||||
{"type": "网页", "value": 45}, {"type": "微信", "value": 28},
|
||||
{"type": "APP", "value": 18}, {"type": "电话", "value": 6}, {"type": "邮件", "value": 3},
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var sessions []model.Session
|
||||
var channels []model.Channel
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&sessions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
channelTypes := make(map[uint]string)
|
||||
for _, channel := range channels {
|
||||
channelTypes[channel.ID] = channel.Type
|
||||
}
|
||||
labels := map[string]string{"web": "网页", "wechat": "微信", "app": "APP", "phone": "电话工单", "email": "邮件"}
|
||||
counts := make(map[string]int)
|
||||
for _, session := range sessions {
|
||||
channelType := channelTypes[session.ChannelID]
|
||||
if channelType == "" {
|
||||
channelType = "unknown"
|
||||
}
|
||||
counts[channelType]++
|
||||
}
|
||||
keys := make([]string, 0, len(counts))
|
||||
for key := range counts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
data := make([]gin.H, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
label := labels[key]
|
||||
if label == "" {
|
||||
label = key
|
||||
}
|
||||
data = append(data, gin.H{"type": label, "value": counts[key]})
|
||||
}
|
||||
|
||||
middleware.JSON(c, data)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/server/internal/model"
|
||||
"kefu-sys/server/internal/ws"
|
||||
)
|
||||
|
||||
type WidgetHandler struct{}
|
||||
@@ -13,33 +20,52 @@ type WidgetHandler struct{}
|
||||
func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} }
|
||||
|
||||
type WidgetInitReq struct {
|
||||
ChannelKey string `json:"channel_key" form:"channel_key"`
|
||||
ChannelKey string `json:"channel_key" form:"channel_key"`
|
||||
VisitorName string `json:"visitor_name"`
|
||||
}
|
||||
|
||||
type WidgetMessageReq struct {
|
||||
SessionID uint `json:"session_id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Type string `json:"type"`
|
||||
SessionID uint `json:"session_id" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Type string `json:"type"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
}
|
||||
|
||||
type WidgetRatingReq struct {
|
||||
SessionID uint `json:"session_id" binding:"required"`
|
||||
Score int `json:"score" binding:"required"`
|
||||
Text string `json:"text"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
}
|
||||
|
||||
var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`)
|
||||
|
||||
func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
var req WidgetInitReq
|
||||
if err := c.ShouldBindQuery(&req); err != nil && c.ShouldBindJSON(&req) != nil {
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
if c.Request.Method == http.MethodPost && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ChannelKey == "" {
|
||||
req.ChannelKey = c.Query("channel_key")
|
||||
}
|
||||
if !channelKeyPattern.MatchString(req.ChannelKey) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道标识无效"})
|
||||
return
|
||||
}
|
||||
|
||||
var channel model.Channel
|
||||
if err := model.DB.Where("script_code LIKE ?", "%"+req.ChannelKey+"%").Or("script_code LIKE ?", "%"+req.ChannelKey+"%").First(&channel).Error; err != nil {
|
||||
// 如果没有匹配的渠道,使用第一个启用的渠道
|
||||
if err := model.DB.Where("type = ? AND status = ?", "web", "enabled").First(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.
|
||||
Where("type = ? AND status = ? AND script_code LIKE ?", "web", "enabled", "%data-id=\""+req.ChannelKey+"\"%").
|
||||
First(&channel).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在或已关闭"})
|
||||
return
|
||||
}
|
||||
|
||||
name := req.VisitorName
|
||||
@@ -60,27 +86,61 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
model.DB.Create(&customer)
|
||||
}
|
||||
|
||||
visitorToken, visitorTokenHash, err := model.NewVisitorToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建访客会话失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
session := model.Session{
|
||||
TenantID: channel.TenantID,
|
||||
ChannelID: channel.ID,
|
||||
CustomerID: customer.ID,
|
||||
Status: "waiting",
|
||||
Priority: "normal",
|
||||
TenantID: channel.TenantID,
|
||||
ChannelID: channel.ID,
|
||||
CustomerID: customer.ID,
|
||||
VisitorTokenHash: visitorTokenHash,
|
||||
Status: "waiting",
|
||||
Priority: "normal",
|
||||
}
|
||||
if err := model.DB.Create(&session).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"})
|
||||
return
|
||||
}
|
||||
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
model.DB.Create(&session)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"session_id": session.ID,
|
||||
"customer_id": customer.ID,
|
||||
"channel_id": channel.ID,
|
||||
"tenant_id": channel.TenantID,
|
||||
"session_id": session.ID,
|
||||
"customer_id": customer.ID,
|
||||
"channel_id": channel.ID,
|
||||
"tenant_id": channel.TenantID,
|
||||
"visitor_token": visitorToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
|
||||
if token := c.GetHeader("X-Visitor-Token"); token != "" {
|
||||
return token
|
||||
}
|
||||
return bodyToken
|
||||
}
|
||||
|
||||
func loadVisitorSession(c *gin.Context, sessionID uint, token string) (*model.Session, bool) {
|
||||
var session model.Session
|
||||
if err := model.DB.First(&session, sessionID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||
return nil, false
|
||||
}
|
||||
if !model.VerifyVisitorToken(&session, token) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "访客会话凭证无效"})
|
||||
return nil, false
|
||||
}
|
||||
return &session, true
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
var req WidgetMessageReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -91,20 +151,24 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
req.Type = "text"
|
||||
}
|
||||
|
||||
var session model.Session
|
||||
if err := model.DB.First(&session, req.SessionID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||
session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是等待中的会话,更新为活跃
|
||||
if session.Status == "waiting" {
|
||||
model.DB.Model(&session).Update("status", "active")
|
||||
if err := model.DB.Model(session).Update("status", "active").Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新会话失败"})
|
||||
return
|
||||
}
|
||||
session.Status = "active"
|
||||
}
|
||||
|
||||
var maxSeq int
|
||||
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq)
|
||||
|
||||
custID := session.CustomerID
|
||||
msg := model.Message{
|
||||
SessionID: session.ID,
|
||||
@@ -112,23 +176,110 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||
SenderID: &custID,
|
||||
Content: req.Content,
|
||||
Type: req.Type,
|
||||
Seq: maxSeq + 1,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&msg).Error; err != nil {
|
||||
if err := model.CreateMessage(&msg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||
return
|
||||
}
|
||||
if payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
||||
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
||||
}
|
||||
|
||||
func visitorTokenFromWebSocket(c *gin.Context) string {
|
||||
protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",")
|
||||
if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-visitor-v1" {
|
||||
return strings.TrimSpace(protocols[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) Connect(c *gin.Context) {
|
||||
sessionID, err := strconv.ParseUint(c.Query("session_id"), 10, 64)
|
||||
if err != nil || sessionID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"})
|
||||
return
|
||||
}
|
||||
session, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromWebSocket(c))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
client, err := ws.UpgradeVisitor(c.Writer, c.Request, session.TenantID, session.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"})
|
||||
return
|
||||
}
|
||||
ws.HandleWebSocket(client)
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) SubmitRating(c *gin.Context) {
|
||||
var req WidgetRatingReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Score < 1 || req.Score > 5 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "评分应为 1 至 5 星"})
|
||||
return
|
||||
}
|
||||
session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status != "ended" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话结束后才能评价"})
|
||||
return
|
||||
}
|
||||
if session.SatisfactionScore != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该会话已评价"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Session{}).Where("id = ? AND satisfaction_score IS NULL", session.ID).
|
||||
Updates(map[string]interface{}{"satisfaction_score": req.Score, "satisfaction_text": req.Text})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return tx.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).
|
||||
Updates(map[string]interface{}{
|
||||
"satisfaction_sum": gorm.Expr("satisfaction_sum + ?", req.Score),
|
||||
"satisfaction_count": gorm.Expr("satisfaction_count + 1"),
|
||||
}).Error
|
||||
}); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该会话已评价"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "提交评价失败"})
|
||||
return
|
||||
}
|
||||
|
||||
middleware.JSON(c, gin.H{"message": "感谢您的评价"})
|
||||
}
|
||||
|
||||
func (h *WidgetHandler) GetMessages(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
sessionID, err := strconv.ParseUint(c.Query("session_id"), 10, 64)
|
||||
if err != nil || sessionID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"})
|
||||
return
|
||||
}
|
||||
if _, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromRequest(c, "")); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var messages []model.Message
|
||||
model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages)
|
||||
if err := model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": messages})
|
||||
}
|
||||
|
||||
@@ -15,9 +15,8 @@ func NewWsHandler() *WsHandler { return &WsHandler{} }
|
||||
func (h *WsHandler) Connect(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
role, _ := c.Get("role")
|
||||
|
||||
client, err := ws.Upgrade(c.Writer, c.Request, userID, tenantID, role.(string))
|
||||
client, err := ws.UpgradeAgent(c.Writer, c.Request, userID, tenantID, middleware.GetRole(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user