修复访客浏览轨迹延迟与排序,增加演示站

保留 SPA hash 以正确区分页面;延迟采集 title 避免慢一页;轨迹改为最新在上;补充 demo-shop 测试页与 Vite 静态路由。
This commit is contained in:
yml2213
2026-07-18 23:22:47 +08:00
parent 81852e0ddd
commit 970bae16d1
9 changed files with 651 additions and 44 deletions
+6 -2
View File
@@ -67,8 +67,12 @@ func sanitizePageURL(raw string) string {
if changed {
u.RawQuery = q.Encode()
}
// 去掉 fragment
u.Fragment = ""
// 保留 hash:大量 SPA(含演示站 demo-shop)靠 #/path 区分页面;
// 若去掉 fragment,换页后 URL 相同会被服务端当成重复而丢弃轨迹。
// 仅截断过长 hash,避免异常数据撑爆字段。
if utf8.RuneCountInString(u.Fragment) > 200 {
u.Fragment = string([]rune(u.Fragment)[:200])
}
out := u.String()
return truncateRunes(out, maxPageURLLen)
}
+11 -2
View File
@@ -7,13 +7,22 @@ import (
)
func TestSanitizePageURL(t *testing.T) {
got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#hash")
if got == "" || strings.Contains(got, "token=") || strings.Contains(got, "#") {
got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#/cart")
if got == "" || strings.Contains(got, "token=") {
t.Fatalf("sanitize failed: %q", got)
}
if !strings.Contains(got, "ok=1") {
t.Fatalf("should keep ok param: %q", got)
}
// SPA hash 路由必须保留,否则换页轨迹会被去重丢掉
if !strings.Contains(got, "#/cart") {
t.Fatalf("should keep hash fragment for SPA: %q", got)
}
home := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/")
cart := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/cart")
if home == "" || cart == "" || home == cart {
t.Fatalf("hash pages must differ: home=%q cart=%q", home, cart)
}
if sanitizePageURL("javascript:alert(1)") != "" {
t.Fatal("reject javascript")
}
+2 -1
View File
@@ -353,7 +353,8 @@ func (h *SessionHandler) Get(c *gin.Context) {
var events []model.SessionEvent
model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events)
var pageViews []model.VisitorPageView
model.DB.Where("session_id = ?", session.ID).Order("entered_at asc").Limit(maxPageViewsPerSession).Find(&pageViews)
// 最新在前,坐席侧无需滚到底
model.DB.Where("session_id = ?", session.ID).Order("entered_at desc").Limit(maxPageViewsPerSession).Find(&pageViews)
var pendingCount int64
model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount)
+30 -7
View File
@@ -274,13 +274,34 @@ func (h *WidgetHandler) PageView(c *gin.Context) {
pageTitle := sanitizePageTitle(req.PageTitle)
now := time.Now()
// 与当前页相同只刷新 last_seen,不重复轨迹
// 与当前页相同只刷新活跃时间/标题,不重复轨迹
if strings.TrimSpace(session.CurrentURL) == pageURL {
_ = model.DB.Model(session).Updates(map[string]interface{}{
"last_seen_at": now,
"current_title": pageTitle,
}).Error
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true}})
updates := map[string]interface{}{
"last_seen_at": now,
}
titleFixed := false
if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) {
updates["current_title"] = pageTitle
// 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title)
var last model.VisitorPageView
if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL).
Order("entered_at desc").First(&last).Error; err == nil {
_ = model.DB.Model(&last).Update("title", pageTitle).Error
titleFixed = true
if payload, err := ws.NewEvent("page_view", session.ID, gin.H{
"id": last.ID,
"session_id": last.SessionID,
"url": pageURL,
"title": pageTitle,
"entered_at": last.EnteredAt,
"title_fix": true,
}); err == nil {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
}
}
}
_ = model.DB.Model(session).Updates(updates).Error
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true, "title_updated": titleFixed}})
return
}
@@ -324,7 +345,9 @@ func (h *WidgetHandler) PageView(c *gin.Context) {
"title": pv.Title,
"entered_at": pv.EnteredAt,
}); err == nil {
ws.DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload)
// 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor
// 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv})