package handler_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"kefu-sys/server/internal/handler"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
)
func setupRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
dsn := fmt.Sprintf("file:kefu_handler_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := model.Migrate(db); err != nil {
t.Fatalf("迁移测试数据库失败: %v", err)
}
model.DB = db
middleware.InitJWT("test-secret")
router := gin.New()
handler.SetupRoutes(router)
return router
}
func createTenant(t *testing.T, name, status string) model.Tenant {
t.Helper()
tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)}
if err := model.DB.Create(&tenant).Error; err != nil {
t.Fatalf("创建租户失败: %v", err)
}
return tenant
}
func createUser(t *testing.T, tenantID uint, username, role string) model.User {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
if err != nil {
t.Fatalf("生成密码失败: %v", err)
}
user := model.User{
TenantID: tenantID, Username: username, PasswordHash: string(hash),
Nickname: username, Role: role, Status: "online",
}
if err := model.DB.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
return user
}
func bearerRequest(t *testing.T, method, target string, body []byte, user model.User) *http.Request {
t.Helper()
token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role)
if err != nil {
t.Fatalf("生成令牌失败: %v", err)
}
req := httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return req
}
func TestPublicRegisterIsUnavailableAndSuspendedTenantIsBlocked(t *testing.T) {
router := setupRouter(t)
suspended := createTenant(t, "已暂停租户", "suspended")
createUser(t, suspended.ID, "suspended-agent", "agent")
registerRecorder := httptest.NewRecorder()
router.ServeHTTP(registerRecorder, httptest.NewRequest(http.MethodPost, "/api/register", nil))
if registerRecorder.Code != http.StatusNotFound {
t.Fatalf("公开注册接口状态码 = %d,期望 %d", registerRecorder.Code, http.StatusNotFound)
}
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewBufferString(`{"username":"suspended-agent","password":"password123"}`))
loginRequest.Header.Set("Content-Type", "application/json")
router.ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusForbidden {
t.Fatalf("暂停租户登录状态码 = %d,期望 %d", loginRecorder.Code, http.StatusForbidden)
}
}
func TestTenantSuspensionTakesEffectForExistingToken(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "正常租户", "normal")
user := createUser(t, tenant.ID, "normal-agent", "agent")
if err := model.DB.Model(&tenant).Update("status", "suspended").Error; err != nil {
t.Fatalf("暂停租户失败: %v", err)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, user))
if recorder.Code != http.StatusForbidden {
t.Fatalf("已暂停租户的令牌请求状态码 = %d,期望 %d", recorder.Code, http.StatusForbidden)
}
}
func TestEmptyListResponsesUseArraysInsteadOfNull(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "空列表租户", "normal")
user := createUser(t, tenant.ID, "empty-list-agent", "agent")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, user))
if recorder.Code != http.StatusOK {
t.Fatalf("查询空会话列表状态码 = %d,响应 = %s", recorder.Code, recorder.Body.String())
}
var response struct {
List []model.Session `json:"list"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("解析空列表响应失败: %v", err)
}
if response.List == nil {
t.Fatalf("空会话列表被序列化为 null: %s", recorder.Body.String())
}
}
func TestWidgetRequiresVisitorTokenAndDoesNotFallbackChannel(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "Widget 租户", "normal")
channel := model.Channel{
TenantID: tenant.ID, Type: "web", Name: "网页渠道", Status: "enabled",
ScriptCode: ``,
}
if err := model.DB.Create(&channel).Error; err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
invalidRecorder := httptest.NewRecorder()
router.ServeHTTP(invalidRecorder, httptest.NewRequest(http.MethodPost, "/api/widget/init?channel_key=WK_unknown_001", nil))
if invalidRecorder.Code != http.StatusNotFound {
t.Fatalf("未知渠道状态码 = %d,期望 %d", invalidRecorder.Code, http.StatusNotFound)
}
initRecorder := httptest.NewRecorder()
initRequest := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_secure_001","visitor_name":"测试访客"}`))
initRequest.Header.Set("Content-Type", "application/json")
router.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("初始化 Widget 状态码 = %d,响应 = %s", initRecorder.Code, initRecorder.Body.String())
}
var initResponse struct {
Code int `json:"code"`
Data struct {
SessionID uint `json:"session_id"`
VisitorToken string `json:"visitor_token"`
} `json:"data"`
}
if err := json.Unmarshal(initRecorder.Body.Bytes(), &initResponse); err != nil {
t.Fatalf("解析初始化响应失败: %v", err)
}
if initResponse.Data.SessionID == 0 || initResponse.Data.VisitorToken == "" {
t.Fatalf("初始化未返回会话私密凭证: %s", initRecorder.Body.String())
}
unauthorizedRecorder := httptest.NewRecorder()
router.ServeHTTP(unauthorizedRecorder, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResponse.Data.SessionID), nil))
if unauthorizedRecorder.Code != http.StatusUnauthorized {
t.Fatalf("无凭证读取消息状态码 = %d,期望 %d", unauthorizedRecorder.Code, http.StatusUnauthorized)
}
sendRecorder := httptest.NewRecorder()
sendRequest := httptest.NewRequest(http.MethodPost, "/api/widget/message", bytes.NewBufferString(fmt.Sprintf(`{"session_id":%d,"content":"需要帮助"}`, initResponse.Data.SessionID)))
sendRequest.Header.Set("Content-Type", "application/json")
sendRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken)
router.ServeHTTP(sendRecorder, sendRequest)
if sendRecorder.Code != http.StatusOK {
t.Fatalf("携带凭证发送消息状态码 = %d,响应 = %s", sendRecorder.Code, sendRecorder.Body.String())
}
readRecorder := httptest.NewRecorder()
readRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResponse.Data.SessionID), nil)
readRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken)
router.ServeHTTP(readRecorder, readRequest)
if readRecorder.Code != http.StatusOK {
t.Fatalf("携带凭证读取消息状态码 = %d,响应 = %s", readRecorder.Code, readRecorder.Body.String())
}
if err := model.DB.Model(&model.Session{}).Where("id = ?", initResponse.Data.SessionID).Update("status", "ended").Error; err != nil {
t.Fatalf("准备评价会话失败: %v", err)
}
ratingRecorder := httptest.NewRecorder()
ratingRequest := httptest.NewRequest(http.MethodPost, "/api/widget/rating", bytes.NewBufferString(fmt.Sprintf(`{"session_id":%d,"score":5,"text":"服务很好"}`, initResponse.Data.SessionID)))
ratingRequest.Header.Set("Content-Type", "application/json")
ratingRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken)
router.ServeHTTP(ratingRecorder, ratingRequest)
if ratingRecorder.Code != http.StatusOK {
t.Fatalf("提交评价状态码 = %d,响应 = %s", ratingRecorder.Code, ratingRecorder.Body.String())
}
var ratedSession model.Session
if err := model.DB.First(&ratedSession, initResponse.Data.SessionID).Error; err != nil || ratedSession.SatisfactionScore == nil || *ratedSession.SatisfactionScore != 5 {
t.Fatalf("会话评分未正确保存: session=%+v err=%v", ratedSession, err)
}
}
func TestAgentCannotAccessOtherTenantSessionAndCanEndOwnSession(t *testing.T) {
router := setupRouter(t)
tenantA := createTenant(t, "租户 A", "normal")
tenantB := createTenant(t, "租户 B", "normal")
agentA := createUser(t, tenantA.ID, "agent-a", "agent")
agentA2 := createUser(t, tenantA.ID, "agent-a2", "agent")
agentB := createUser(t, tenantB.ID, "agent-b", "agent")
customerA := model.Customer{TenantID: tenantA.ID, Name: "客户 A"}
customerB := model.Customer{TenantID: tenantB.ID, Name: "客户 B"}
if err := model.DB.Create(&customerA).Error; err != nil {
t.Fatalf("创建客户 A 失败: %v", err)
}
if err := model.DB.Create(&customerB).Error; err != nil {
t.Fatalf("创建客户 B 失败: %v", err)
}
sessionA := model.Session{TenantID: tenantA.ID, CustomerID: customerA.ID, AgentID: &agentA.ID, Status: "active", Priority: "normal"}
sessionA2 := model.Session{TenantID: tenantA.ID, CustomerID: customerA.ID, AgentID: &agentA2.ID, Status: "active", Priority: "normal"}
sessionB := model.Session{TenantID: tenantB.ID, CustomerID: customerB.ID, AgentID: &agentB.ID, Status: "active", Priority: "normal"}
if err := model.DB.Create(&sessionA).Error; err != nil {
t.Fatalf("创建会话 A 失败: %v", err)
}
if err := model.DB.Create(&sessionB).Error; err != nil {
t.Fatalf("创建会话 B 失败: %v", err)
}
if err := model.DB.Create(&sessionA2).Error; err != nil {
t.Fatalf("创建会话 A2 失败: %v", err)
}
crossTenantRecorder := httptest.NewRecorder()
router.ServeHTTP(crossTenantRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/sessions/%d", sessionB.ID), nil, agentA))
if crossTenantRecorder.Code != http.StatusForbidden {
t.Fatalf("跨租户读取会话状态码 = %d,期望 %d", crossTenantRecorder.Code, http.StatusForbidden)
}
otherAgentRecorder := httptest.NewRecorder()
router.ServeHTTP(otherAgentRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/sessions/%d", sessionA2.ID), nil, agentA))
if otherAgentRecorder.Code != http.StatusForbidden {
t.Fatalf("读取同租户其他客服会话状态码 = %d,期望 %d", otherAgentRecorder.Code, http.StatusForbidden)
}
endRecorder := httptest.NewRecorder()
router.ServeHTTP(endRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/end?reason=resolved", sessionA.ID), []byte(`{}`), agentA))
if endRecorder.Code != http.StatusOK {
t.Fatalf("结束自己的会话状态码 = %d,响应 = %s", endRecorder.Code, endRecorder.Body.String())
}
var ended model.Session
if err := model.DB.First(&ended, sessionA.ID).Error; err != nil {
t.Fatalf("读取已结束会话失败: %v", err)
}
if ended.Status != "ended" || ended.EndedAt == nil {
t.Fatalf("会话结束字段未正确写入: %+v", ended)
}
}
func TestStatisticsAreCalculatedFromPersistedSessionsAndMessages(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "统计租户", "normal")
supervisor := createUser(t, tenant.ID, "stats-supervisor", "supervisor")
agent := createUser(t, tenant.ID, "stats-agent", "agent")
customer := model.Customer{TenantID: tenant.ID, Name: "统计客户"}
if err := model.DB.Create(&customer).Error; err != nil {
t.Fatalf("创建统计客户失败: %v", err)
}
score := 5
now := time.Now()
session := model.Session{
TenantID: tenant.ID, CustomerID: customer.ID, AgentID: &agent.ID, Status: "ended", Priority: "normal",
EndReason: "resolved", EndedAt: &now, SatisfactionScore: &score,
}
if err := model.DB.Create(&session).Error; err != nil {
t.Fatalf("创建统计会话失败: %v", err)
}
visitorMessage := model.Message{SessionID: session.ID, SenderType: "visitor", Content: "咨询", Type: "text", Seq: 1, SentAt: now.Add(-30 * time.Second)}
agentMessage := model.Message{SessionID: session.ID, SenderType: "agent", SenderID: &agent.ID, Content: "回复", Type: "text", Seq: 2, SentAt: now.Add(-10 * time.Second)}
if err := model.DB.Create(&[]model.Message{visitorMessage, agentMessage}).Error; err != nil {
t.Fatalf("创建统计消息失败: %v", err)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/statistics/kpi", nil, supervisor))
if recorder.Code != http.StatusOK {
t.Fatalf("查询统计状态码 = %d,响应 = %s", recorder.Code, recorder.Body.String())
}
var response struct {
Code int `json:"code"`
Data struct {
TotalSessions int `json:"total_sessions"`
TotalMessages int `json:"total_messages"`
AvgResponseTime float64 `json:"avg_response_time"`
SatisfactionAvg float64 `json:"satisfaction_avg"`
FirstResolveRate float64 `json:"first_resolve_rate"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("解析统计响应失败: %v", err)
}
if response.Data.TotalSessions != 1 || response.Data.TotalMessages != 2 || response.Data.AvgResponseTime != 20 || response.Data.SatisfactionAvg != 5 || response.Data.FirstResolveRate != 100 {
t.Fatalf("统计值不正确: %+v", response.Data)
}
}
func TestKnowledgeEntryRespectsPlanCapacity(t *testing.T) {
router := setupRouter(t)
plan := model.Plan{Name: "测试套餐", KBLimit: 1, Status: "active"}
if err := model.DB.Create(&plan).Error; err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
tenant := createTenant(t, "容量租户", "normal")
if err := model.DB.Model(&tenant).Update("plan_id", plan.ID).Error; err != nil {
t.Fatalf("关联套餐失败: %v", err)
}
supervisor := createUser(t, tenant.ID, "knowledge-supervisor", "supervisor")
category := model.Category{TenantID: tenant.ID, Name: "常见问题"}
if err := model.DB.Create(&category).Error; err != nil {
t.Fatalf("创建分类失败: %v", err)
}
entry := model.KnowledgeEntry{TenantID: tenant.ID, CategoryID: category.ID, Title: "已有条目", Content: "内容"}
if err := model.DB.Create(&entry).Error; err != nil {
t.Fatalf("创建已有条目失败: %v", err)
}
recorder := httptest.NewRecorder()
body := []byte(fmt.Sprintf(`{"category_id":%d,"title":"超额条目","content":"内容"}`, category.ID))
router.ServeHTTP(recorder, bearerRequest(t, http.MethodPost, "/api/knowledge/entries", body, supervisor))
if recorder.Code != http.StatusConflict {
t.Fatalf("超出知识库容量状态码 = %d,期望 %d,响应 = %s", recorder.Code, http.StatusConflict, recorder.Body.String())
}
}
func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "工作台租户", "normal")
agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent")
agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "agent")
customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"}
if err := model.DB.Create(&customer).Error; err != nil {
t.Fatalf("创建工作台客户失败: %v", err)
}
session := model.Session{TenantID: tenant.ID, CustomerID: customer.ID, Status: "waiting", Priority: "normal"}
if err := model.DB.Create(&session).Error; err != nil {
t.Fatalf("创建等待会话失败: %v", err)
}
visitorMessage := model.Message{SessionID: session.ID, SenderType: "visitor", Content: "需要咨询", Type: "text", Seq: 1, SentAt: time.Now()}
if err := model.DB.Create(&visitorMessage).Error; err != nil {
t.Fatalf("创建访客消息失败: %v", err)
}
availableRecorder := httptest.NewRecorder()
router.ServeHTTP(availableRecorder, bearerRequest(t, http.MethodGet, "/api/agents/available", nil, agentOne))
if availableRecorder.Code != http.StatusOK {
t.Fatalf("查询在线客服失败: %d %s", availableRecorder.Code, availableRecorder.Body.String())
}
claimRecorder := httptest.NewRecorder()
router.ServeHTTP(claimRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/assign", session.ID), []byte(`{}`), agentOne))
if claimRecorder.Code != http.StatusOK {
t.Fatalf("领取会话失败: %d %s", claimRecorder.Code, claimRecorder.Body.String())
}
unreadRecorder := httptest.NewRecorder()
router.ServeHTTP(unreadRecorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, agentOne))
var unreadResponse struct {
List []struct {
ID uint `json:"id"`
UnreadCount int `json:"unread_count"`
} `json:"list"`
}
if err := json.Unmarshal(unreadRecorder.Body.Bytes(), &unreadResponse); err != nil {
t.Fatalf("解析未读列表失败: %v", err)
}
if len(unreadResponse.List) != 1 || unreadResponse.List[0].UnreadCount != 1 {
t.Fatalf("领取后的未读数不正确: %s", unreadRecorder.Body.String())
}
readRecorder := httptest.NewRecorder()
router.ServeHTTP(readRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/read", session.ID), []byte(`{}`), agentOne))
if readRecorder.Code != http.StatusOK {
t.Fatalf("标记已读失败: %d %s", readRecorder.Code, readRecorder.Body.String())
}
readListRecorder := httptest.NewRecorder()
router.ServeHTTP(readListRecorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, agentOne))
var readListResponse struct {
List []struct {
UnreadCount int `json:"unread_count"`
} `json:"list"`
}
if err := json.Unmarshal(readListRecorder.Body.Bytes(), &readListResponse); err != nil || len(readListResponse.List) != 1 || readListResponse.List[0].UnreadCount != 0 {
t.Fatalf("标记已读后的未读数不正确: %s", readListRecorder.Body.String())
}
noteRecorder := httptest.NewRecorder()
router.ServeHTTP(noteRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/notes", session.ID), []byte(`{"content":"已核对客户需求"}`), agentOne))
if noteRecorder.Code != http.StatusOK {
t.Fatalf("添加内部备注失败: %d %s", noteRecorder.Code, noteRecorder.Body.String())
}
transferRecorder := httptest.NewRecorder()
transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID))
router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne))
if transferRecorder.Code != http.StatusOK {
t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String())
}
oldAgentMessageRecorder := httptest.NewRecorder()
router.ServeHTTP(oldAgentMessageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), []byte(`{"content":"越权回复"}`), agentOne))
if oldAgentMessageRecorder.Code != http.StatusForbidden {
t.Fatalf("转接后原客服仍可回复: %d %s", oldAgentMessageRecorder.Code, oldAgentMessageRecorder.Body.String())
}
imageRecorder := httptest.NewRecorder()
imageBody := []byte(`{"content":"data:image/png;base64,aGVsbG8=","type":"image"}`)
router.ServeHTTP(imageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), imageBody, agentTwo))
if imageRecorder.Code != http.StatusOK {
t.Fatalf("发送图片消息失败: %d %s", imageRecorder.Code, imageRecorder.Body.String())
}
invalidEndRecorder := httptest.NewRecorder()
router.ServeHTTP(invalidEndRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/end?reason=bad_reason", session.ID), []byte(`{}`), agentTwo))
if invalidEndRecorder.Code != http.StatusBadRequest {
t.Fatalf("无效结束原因未被拦截: %d %s", invalidEndRecorder.Code, invalidEndRecorder.Body.String())
}
var savedSession model.Session
if err := model.DB.First(&savedSession, session.ID).Error; err != nil {
t.Fatalf("读取工作台会话失败: %v", err)
}
if savedSession.AgentID == nil || *savedSession.AgentID != agentTwo.ID || savedSession.LastReadSeq != 2 {
t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession)
}
}
func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "自动分配租户", "normal")
channel := model.Channel{
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
ScriptCode: ``,
}
if err := model.DB.Create(&channel).Error; err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
// 无在线客服 → 离线模式
initRecorder := httptest.NewRecorder()
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"离线访客"}`))
initReq.Header.Set("Content-Type", "application/json")
router.ServeHTTP(initRecorder, initReq)
if initRecorder.Code != http.StatusOK {
t.Fatalf("离线初始化失败: %s", initRecorder.Body.String())
}
var offlineInit struct {
Data struct {
SessionID uint `json:"session_id"`
VisitorToken string `json:"visitor_token"`
AgentsOnline bool `json:"agents_online"`
OfflinePrompt string `json:"offline_prompt"`
SessionStatus string `json:"session_status"`
} `json:"data"`
}
if err := json.Unmarshal(initRecorder.Body.Bytes(), &offlineInit); err != nil {
t.Fatalf("解析离线初始化失败: %v", err)
}
if offlineInit.Data.AgentsOnline || offlineInit.Data.OfflinePrompt == "" || offlineInit.Data.SessionStatus != "waiting" {
t.Fatalf("期望离线等待会话: %+v", offlineInit.Data)
}
leaveRecorder := httptest.NewRecorder()
leaveBody := fmt.Sprintf(`{"session_id":%d,"content":"请回电处理订单问题","name":"张留言","phone":"13800138000","email":"leave@example.com"}`, offlineInit.Data.SessionID)
leaveReq := httptest.NewRequest(http.MethodPost, "/api/widget/leave-message", bytes.NewBufferString(leaveBody))
leaveReq.Header.Set("Content-Type", "application/json")
leaveReq.Header.Set("X-Visitor-Token", offlineInit.Data.VisitorToken)
router.ServeHTTP(leaveRecorder, leaveReq)
if leaveRecorder.Code != http.StatusOK {
t.Fatalf("离线留言失败: %s", leaveRecorder.Body.String())
}
var session model.Session
if err := model.DB.First(&session, offlineInit.Data.SessionID).Error; err != nil {
t.Fatalf("查询会话失败: %v", err)
}
var customer model.Customer
if err := model.DB.First(&customer, session.CustomerID).Error; err != nil {
t.Fatalf("查询客户失败: %v", err)
}
if customer.Phone != "13800138000" || customer.Email != "leave@example.com" || customer.Name != "张留言" {
t.Fatalf("联系方式未沉淀: %+v", customer)
}
var msgCount int64
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Count(&msgCount)
if msgCount != 1 {
t.Fatalf("留言消息数 = %d", msgCount)
}
var event model.SessionEvent
if err := model.DB.Where("session_id = ? AND action = ?", session.ID, "offline_leave").First(&event).Error; err != nil {
t.Fatalf("未记录离线留言事件: %v", err)
}
// 有在线客服 → 自动分配
agent := createUser(t, tenant.ID, "auto-agent-1", "agent")
init2 := httptest.NewRecorder()
init2Req := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"在线访客"}`))
init2Req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(init2, init2Req)
if init2.Code != http.StatusOK {
t.Fatalf("在线初始化失败: %s", init2.Body.String())
}
var onlineInit struct {
Data struct {
SessionID uint `json:"session_id"`
AgentsOnline bool `json:"agents_online"`
SessionStatus string `json:"session_status"`
AgentID uint `json:"agent_id"`
AgentName string `json:"agent_name"`
} `json:"data"`
}
if err := json.Unmarshal(init2.Body.Bytes(), &onlineInit); err != nil {
t.Fatalf("解析在线初始化失败: %v", err)
}
if !onlineInit.Data.AgentsOnline || onlineInit.Data.SessionStatus != "active" || onlineInit.Data.AgentID != agent.ID {
t.Fatalf("期望自动分配给在线客服: %+v agent=%d", onlineInit.Data, agent.ID)
}
var assigned model.Session
if err := model.DB.First(&assigned, onlineInit.Data.SessionID).Error; err != nil {
t.Fatalf("查询已分配会话失败: %v", err)
}
if assigned.AgentID == nil || *assigned.AgentID != agent.ID || assigned.Status != "active" {
t.Fatalf("会话分配状态不正确: %+v", assigned)
}
var assignEvent model.SessionEvent
if err := model.DB.Where("session_id = ? AND action = ?", assigned.ID, "auto_assign").First(&assignEvent).Error; err != nil {
t.Fatalf("未记录自动分配事件: %v", err)
}
}
func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "渠道租户", "normal")
admin := createUser(t, tenant.ID, "ch-admin", "admin")
agent := createUser(t, tenant.ID, "ch-agent", "agent")
channel := model.Channel{
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
ScriptCode: ``,
}
if err := model.DB.Create(&channel).Error; err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, bearerRequest(t, http.MethodGet, "/api/channels", nil, agent))
if listRecorder.Code != http.StatusOK {
t.Fatalf("客服查看渠道列表失败: %s", listRecorder.Body.String())
}
denyRecorder := httptest.NewRecorder()
router.ServeHTTP(denyRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/channels/%d", channel.ID), []byte(`{"status":"disabled"}`), agent))
if denyRecorder.Code != http.StatusForbidden {
t.Fatalf("客服禁用渠道应 403,实际 %d %s", denyRecorder.Code, denyRecorder.Body.String())
}
okRecorder := httptest.NewRecorder()
router.ServeHTTP(okRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/channels/%d", channel.ID), []byte(`{"status":"disabled"}`), admin))
if okRecorder.Code != http.StatusOK {
t.Fatalf("管理员禁用渠道失败: %s", okRecorder.Body.String())
}
var updated model.Channel
if err := model.DB.First(&updated, channel.ID).Error; err != nil || updated.Status != "disabled" {
t.Fatalf("渠道状态未更新: %+v err=%v", updated, err)
}
}
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "业务CRUD租户", "normal")
admin := createUser(t, tenant.ID, "biz-admin", "admin")
createCustomerRec := httptest.NewRecorder()
router.ServeHTTP(createCustomerRec, bearerRequest(t, http.MethodPost, "/api/customers", []byte(`{"name":"测试客户甲","phone":"13900001111","tags":"[\"新客户\"]","status":"offline","source":"手动"}`), admin))
if createCustomerRec.Code != http.StatusOK {
t.Fatalf("创建客户失败: %s", createCustomerRec.Body.String())
}
var createCustomerResp struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(createCustomerRec.Body.Bytes(), &createCustomerResp); err != nil || createCustomerResp.Data.ID == 0 {
t.Fatalf("解析客户创建响应失败: %v body=%s", err, createCustomerRec.Body.String())
}
updateCustomerRec := httptest.NewRecorder()
router.ServeHTTP(updateCustomerRec, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/customers/%d", createCustomerResp.Data.ID), []byte(`{"tags":"[\"VIP客户\",\"活跃\"]"}`), admin))
if updateCustomerRec.Code != http.StatusOK {
t.Fatalf("更新客户失败: %s", updateCustomerRec.Body.String())
}
createCatRec := httptest.NewRecorder()
router.ServeHTTP(createCatRec, bearerRequest(t, http.MethodPost, "/api/knowledge/categories", []byte(`{"name":"产品FAQ"}`), admin))
if createCatRec.Code != http.StatusOK {
t.Fatalf("创建分类失败: %s", createCatRec.Body.String())
}
var catResp struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(createCatRec.Body.Bytes(), &catResp); err != nil || catResp.Data.ID == 0 {
t.Fatalf("解析分类响应失败: %v", err)
}
createEntryRec := httptest.NewRecorder()
body := fmt.Sprintf(`{"title":"如何退货","content":"7天无理由退货","category_id":%d,"status":"published"}`, catResp.Data.ID)
router.ServeHTTP(createEntryRec, bearerRequest(t, http.MethodPost, "/api/knowledge/entries", []byte(body), admin))
if createEntryRec.Code != http.StatusOK {
t.Fatalf("创建知识条目失败: %s", createEntryRec.Body.String())
}
}
func TestAdminTenantLifecycleAndPlanToggle(t *testing.T) {
router := setupRouter(t)
platform := createUser(t, 0, "platform-ops", "platform_admin")
plan := model.Plan{Name: "测试套餐", PriceMonthly: 199, Seats: 3, StorageDays: 30, KBLimit: 20, Status: "active"}
if err := model.DB.Create(&plan).Error; err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
createRec := httptest.NewRecorder()
body := fmt.Sprintf(`{"name":"新开租户A","contact_name":"王经理","contact_phone":"13800138001","plan_id":%d,"seat_count":5,"duration_months":6}`, plan.ID)
router.ServeHTTP(createRec, bearerRequest(t, http.MethodPost, "/api/admin/tenants", []byte(body), platform))
if createRec.Code != http.StatusOK {
t.Fatalf("开通租户失败: %s", createRec.Body.String())
}
var createResp struct {
Data struct {
Tenant struct {
ID uint `json:"id"`
} `json:"tenant"`
AdminUsername string `json:"admin_username"`
} `json:"data"`
}
if err := json.Unmarshal(createRec.Body.Bytes(), &createResp); err != nil || createResp.Data.Tenant.ID == 0 || createResp.Data.AdminUsername == "" {
t.Fatalf("解析开通响应失败: %v body=%s", err, createRec.Body.String())
}
suspendRec := httptest.NewRecorder()
router.ServeHTTP(suspendRec, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/admin/tenants/%d/suspend", createResp.Data.Tenant.ID), []byte(`{}`), platform))
if suspendRec.Code != http.StatusOK {
t.Fatalf("暂停失败: %s", suspendRec.Body.String())
}
var tenant model.Tenant
model.DB.First(&tenant, createResp.Data.Tenant.ID)
if tenant.Status != "suspended" {
t.Fatalf("租户状态应为 suspended: %+v", tenant)
}
resumeRec := httptest.NewRecorder()
router.ServeHTTP(resumeRec, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/admin/tenants/%d/resume", createResp.Data.Tenant.ID), []byte(`{}`), platform))
if resumeRec.Code != http.StatusOK {
t.Fatalf("恢复失败: %s", resumeRec.Body.String())
}
model.DB.First(&tenant, createResp.Data.Tenant.ID)
if tenant.Status != "normal" {
t.Fatalf("恢复后状态应为 normal: %+v", tenant)
}
planRec := httptest.NewRecorder()
router.ServeHTTP(planRec, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/admin/plans/%d", plan.ID), []byte(`{"status":"inactive"}`), platform))
if planRec.Code != http.StatusOK {
t.Fatalf("套餐下架失败: %s", planRec.Body.String())
}
var updatedPlan model.Plan
model.DB.First(&updatedPlan, plan.ID)
if updatedPlan.Status != "inactive" {
t.Fatalf("套餐状态未更新: %+v", updatedPlan)
}
}