feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
CI / Frontend (typecheck + lint + build) (push) Successful in 2m5s
CI / Backend (go vet + test) (push) Failing after 5m20s

This commit is contained in:
yml2213
2026-11-06 18:21:30 +08:00
parent 24fac1bcf1
commit 38d6fc1684
37 changed files with 1071 additions and 123 deletions
@@ -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")