package handler_test import ( "bytes" "encoding/base64" "encoding/json" "fmt" "mime/multipart" "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-cloud/server/internal/config" "kefu-cloud/server/internal/handler" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" "kefu-cloud/server/internal/storage" ) 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() mem := storage.NewMemory("http://localhost:9100/kefu") handler.SetupRoutes(router, mem, config.StorageConfig{ PublicBase: "http://localhost:9100/kefu", MaxUploadMB: 10, MaxImageEdge: 1920, WebPQuality: 80, }) return router } // tinyPNG 1x1 像素 PNG func tinyPNG() []byte { b, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") return b } func multipartImageRequest(t *testing.T, method, target string, fileField string, filename string, data []byte, user model.User) *http.Request { t.Helper() var body bytes.Buffer w := multipart.NewWriter(&body) part, err := w.CreateFormFile(fileField, filename) if err != nil { t.Fatalf("创建 form file 失败: %v", err) } if _, err := part.Write(data); err != nil { t.Fatalf("写入文件失败: %v", err) } _ = w.Close() req := httptest.NewRequest(method, target, &body) req.Header.Set("Content-Type", w.FormDataContentType()) token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role) if err != nil { t.Fatalf("生成令牌失败: %v", err) } req.Header.Set("Authorization", "Bearer "+token) return req } 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()) } uploadRecorder := httptest.NewRecorder() router.ServeHTTP(uploadRecorder, multipartImageRequest(t, http.MethodPost, "/api/uploads", "file", "dot.png", tinyPNG(), agentTwo)) if uploadRecorder.Code != http.StatusOK { t.Fatalf("上传图片失败: %d %s", uploadRecorder.Code, uploadRecorder.Body.String()) } var uploadResp struct { Data struct { URL string `json:"url"` } `json:"data"` } if err := json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil || uploadResp.Data.URL == "" { t.Fatalf("解析上传响应失败: %v body=%s", err, uploadRecorder.Body.String()) } imageRecorder := httptest.NewRecorder() imageBody := []byte(fmt.Sprintf(`{"content":%q,"type":"image"}`, uploadResp.Data.URL)) 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) } } func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { router := setupRouter(t) tenant := createTenant(t, "设置租户", "normal") admin := createUser(t, tenant.ID, "settings-admin", "admin") 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) } // 保存多段欢迎语与离线提示 saveRec := httptest.NewRecorder() saveBody := `{"welcome_messages":[{"type":"text","content":"定制欢迎语ABC"},{"type":"text","content":"第二段欢迎"}],"offline_prompt":"定制离线提示XYZ","work_hours":{"monday":"全天","tuesday":"全天","wednesday":"全天","thursday":"全天","friday":"全天","saturday":"全天","sunday":"全天"}}` router.ServeHTTP(saveRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(saveBody), admin)) if saveRec.Code != http.StatusOK { t.Fatalf("保存设置失败: %s", saveRec.Body.String()) } getRec := httptest.NewRecorder() router.ServeHTTP(getRec, bearerRequest(t, http.MethodGet, "/api/settings", nil, admin)) if getRec.Code != http.StatusOK { t.Fatalf("读取设置失败: %s", getRec.Body.String()) } var getResp struct { Data struct { WelcomeMessage string `json:"welcome_message"` WelcomeMessages []struct { Type string `json:"type"` Content string `json:"content"` } `json:"welcome_messages"` OfflinePrompt string `json:"offline_prompt"` } `json:"data"` } if err := json.Unmarshal(getRec.Body.Bytes(), &getResp); err != nil { t.Fatalf("解析设置失败: %v", err) } if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" { t.Fatalf("设置未持久化: %+v", getResp.Data) } if len(getResp.Data.WelcomeMessages) != 2 || getResp.Data.WelcomeMessages[1].Content != "第二段欢迎" { t.Fatalf("多段欢迎语未持久化: %+v", getResp.Data.WelcomeMessages) } // 无在线客服时 init 应返回定制离线提示 initRec := httptest.NewRecorder() initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_set_001"}`)) initReq.Header.Set("Content-Type", "application/json") router.ServeHTTP(initRec, initReq) if initRec.Code != http.StatusOK { t.Fatalf("widget init 失败: %s", initRec.Body.String()) } var initResp struct { Data struct { WelcomeMessage string `json:"welcome_message"` WelcomeMessages []struct { Type string `json:"type"` Content string `json:"content"` } `json:"welcome_messages"` OfflinePrompt string `json:"offline_prompt"` AgentsOnline bool `json:"agents_online"` } `json:"data"` } if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil { t.Fatalf("解析 init 失败: %v", err) } if initResp.Data.AgentsOnline { t.Fatalf("无客服时应离线") } if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" { t.Fatalf("widget 未使用租户设置: %+v", initResp.Data) } if len(initResp.Data.WelcomeMessages) != 2 { t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages) } }