feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"kefu-cloud/server/internal/handler"
|
||||
"kefu-cloud/server/internal/middleware"
|
||||
"kefu-cloud/server/internal/model"
|
||||
"kefu-cloud/server/internal/service"
|
||||
"kefu-cloud/server/internal/storage"
|
||||
)
|
||||
|
||||
@@ -638,6 +639,156 @@ func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleWebChannelsBindToDomains(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "多网站租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "multi-site-admin", "admin")
|
||||
|
||||
createSite := func(name, domain string) struct {
|
||||
ID uint `json:"id"`
|
||||
ScriptCode string `json:"script_code"`
|
||||
ChannelKey string `json:"channel_key"`
|
||||
SiteDomain string `json:"site_domain"`
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
body := fmt.Sprintf(`{"type":"web","name":%q,"site_domain":%q}`, name, domain)
|
||||
router.ServeHTTP(rec, bearerRequest(t, http.MethodPost, "/api/channels", []byte(body), admin))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("创建网站渠道失败: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
ID uint `json:"id"`
|
||||
ScriptCode string `json:"script_code"`
|
||||
ChannelKey string `json:"channel_key"`
|
||||
SiteDomain string `json:"site_domain"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("解析渠道响应失败: %v", err)
|
||||
}
|
||||
if response.Data.SiteDomain != domain {
|
||||
t.Fatalf("渠道域名未保存: %+v", response.Data)
|
||||
}
|
||||
return response.Data
|
||||
}
|
||||
|
||||
shop := createSite("商城", "shop.example.com")
|
||||
support := createSite("帮助中心", "help.example.com")
|
||||
if shop.ChannelKey == support.ChannelKey {
|
||||
t.Fatal("不同网站渠道必须使用不同 Key")
|
||||
}
|
||||
|
||||
wrongReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
|
||||
bytes.NewBufferString(fmt.Sprintf(`{"channel_key":%q,"page_url":"https://help.example.com/faq"}`, shop.ChannelKey)))
|
||||
wrongReq.Header.Set("Content-Type", "application/json")
|
||||
wrongRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(wrongRec, wrongReq)
|
||||
if wrongRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("跨网站使用渠道 Key 应拒绝: %d %s", wrongRec.Code, wrongRec.Body.String())
|
||||
}
|
||||
|
||||
rightReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
|
||||
bytes.NewBufferString(fmt.Sprintf(`{"channel_key":%q,"page_url":"https://shop.example.com/product/1"}`, shop.ChannelKey)))
|
||||
rightReq.Header.Set("Content-Type", "application/json")
|
||||
rightRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rightRec, rightReq)
|
||||
if rightRec.Code != http.StatusOK {
|
||||
t.Fatalf("同域初始化失败: %d %s", rightRec.Code, rightRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWidgetHeartbeatUpdatesVisitorOnline(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "在线状态租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "presence-admin", "admin")
|
||||
channel := model.Channel{
|
||||
TenantID: tenant.ID, Type: "web", Name: "官网", Status: "enabled",
|
||||
ScriptCode: `<script src="/widget.js" data-id="WK_presence_001"></script>`,
|
||||
}
|
||||
if err := model.DB.Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initRec := httptest.NewRecorder()
|
||||
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
|
||||
bytes.NewBufferString(`{"channel_key":"WK_presence_001","page_url":"https://example.com/"}`))
|
||||
initReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(initRec, initReq)
|
||||
if initRec.Code != http.StatusOK {
|
||||
t.Fatalf("初始化访客失败: %s", initRec.Body.String())
|
||||
}
|
||||
var initResp struct {
|
||||
Data struct {
|
||||
SessionID uint `json:"session_id"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
heartbeat := func(state string) {
|
||||
body := fmt.Sprintf(`{"session_id":%d,"visitor_token":%q,"state":%q}`, initResp.Data.SessionID, initResp.Data.VisitorToken, state)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/widget/heartbeat", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("心跳 %s 失败: %s", state, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat("hidden")
|
||||
var hidden model.Session
|
||||
if err := model.DB.First(&hidden, initResp.Data.SessionID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hidden.VisitorLeftAt == nil || hidden.LastSeenAt == nil || !hidden.LastSeenAt.Before(*hidden.VisitorLeftAt) {
|
||||
t.Fatalf("隐藏页面应记录离开时间: %+v", hidden)
|
||||
}
|
||||
|
||||
heartbeat("visible")
|
||||
var visible model.Session
|
||||
if err := model.DB.First(&visible, initResp.Data.SessionID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visible.VisitorLeftAt != nil || !service.VisitorIsOnline(&visible, time.Now()) {
|
||||
t.Fatalf("恢复可见后应重新在线: %+v", visible)
|
||||
}
|
||||
|
||||
pageBody := fmt.Sprintf(`{"session_id":%d,"visitor_token":%q,"page_url":"https://example.com/next","page_title":"下一页"}`,
|
||||
initResp.Data.SessionID, initResp.Data.VisitorToken)
|
||||
pageRec := httptest.NewRecorder()
|
||||
pageReq := httptest.NewRequest(http.MethodPost, "/api/widget/pageview", bytes.NewBufferString(pageBody))
|
||||
pageReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(pageRec, pageReq)
|
||||
if pageRec.Code != http.StatusOK {
|
||||
t.Fatalf("页面轨迹更新失败: %s", pageRec.Body.String())
|
||||
}
|
||||
var pageViews []model.VisitorPageView
|
||||
if err := model.DB.Where("session_id = ?", initResp.Data.SessionID).Order("entered_at asc").Find(&pageViews).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pageViews) != 2 || pageViews[0].ExitedAt == nil {
|
||||
t.Fatalf("页面切换应关闭上一页轨迹: %+v", pageViews)
|
||||
}
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, bearerRequest(t, http.MethodGet, "/api/sessions?page=1&pageSize=10", nil, admin))
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("读取会话列表失败: %s", listRec.Body.String())
|
||||
}
|
||||
var listResp struct {
|
||||
List []model.Session `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(listRec.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listResp.List) != 1 || !listResp.List[0].VisitorOnline {
|
||||
t.Fatalf("会话列表应返回访客在线状态: %+v", listResp.List)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRoleCodeIsStableAndInvalidPermissionUpdateIsAtomic(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "自定义角色租户", "normal")
|
||||
|
||||
Reference in New Issue
Block a user