访客落地页、在线读秒与浏览轨迹
记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPageURLLen = 1000
|
||||
maxPageTitleLen = 200
|
||||
// 单会话最多保留轨迹条数(超出删最旧)
|
||||
maxPageViewsPerSession = 50
|
||||
)
|
||||
|
||||
// 常见敏感 query 参数,上报 URL 时剥离
|
||||
var sensitiveQueryKeys = map[string]bool{
|
||||
"token": true, "access_token": true, "refresh_token": true,
|
||||
"password": true, "passwd": true, "pwd": true,
|
||||
"phone": true, "mobile": true, "tel": true,
|
||||
"idcard": true, "id_card": true, "secret": true, "key": true,
|
||||
"authorization": true, "auth": true, "session": true, "sid": true,
|
||||
"code": true, "otp": true, "verify": true,
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if max <= 0 || s == "" {
|
||||
return s
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// sanitizePageURL 规范化并脱敏 URL,非法则返回空。
|
||||
func sanitizePageURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(raw) > maxPageURLLen*2 {
|
||||
raw = string([]rune(raw)[:maxPageURLLen*2])
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
// 允许无 scheme 的相对路径拼不成绝对时,若已是 http(s) 失败则截断原文
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
return truncateRunes(raw, maxPageURLLen)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
q := u.Query()
|
||||
changed := false
|
||||
for k := range q {
|
||||
lk := strings.ToLower(k)
|
||||
if sensitiveQueryKeys[lk] || strings.Contains(lk, "token") || strings.Contains(lk, "password") {
|
||||
q.Del(k)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
// 去掉 fragment
|
||||
u.Fragment = ""
|
||||
out := u.String()
|
||||
return truncateRunes(out, maxPageURLLen)
|
||||
}
|
||||
|
||||
func sanitizePageTitle(raw string) string {
|
||||
return truncateRunes(raw, maxPageTitleLen)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
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, "#") {
|
||||
t.Fatalf("sanitize failed: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "ok=1") {
|
||||
t.Fatalf("should keep ok param: %q", got)
|
||||
}
|
||||
if sanitizePageURL("javascript:alert(1)") != "" {
|
||||
t.Fatal("reject javascript")
|
||||
}
|
||||
if sanitizePageURL("") != "" {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePageTitle(t *testing.T) {
|
||||
long := strings.Repeat("测", 300)
|
||||
got := sanitizePageTitle(long)
|
||||
if utf8.RuneCountInString(got) != maxPageTitleLen {
|
||||
t.Fatalf("title len %d", utf8.RuneCountInString(got))
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
widgetApi.GET("/ws", widget.Connect)
|
||||
widgetApi.POST("/rating", widget.SubmitRating)
|
||||
widgetApi.POST("/upload", upload.WidgetUploadImage)
|
||||
widgetApi.POST("/pageview", widget.PageView)
|
||||
widgetApi.POST("/heartbeat", widget.Heartbeat)
|
||||
|
||||
// 需要认证的接口
|
||||
authRequired := api.Group("")
|
||||
|
||||
@@ -352,6 +352,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)
|
||||
var pendingCount int64
|
||||
model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount)
|
||||
|
||||
@@ -365,6 +367,7 @@ func (h *SessionHandler) Get(c *gin.Context) {
|
||||
"session": session,
|
||||
"messages": messages,
|
||||
"events": events,
|
||||
"page_views": pageViews,
|
||||
"pending_count": pendingCount,
|
||||
"max_seq": maxSeq,
|
||||
})
|
||||
|
||||
@@ -22,6 +22,22 @@ func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} }
|
||||
type WidgetInitReq struct {
|
||||
ChannelKey string `json:"channel_key" form:"channel_key"`
|
||||
VisitorName string `json:"visitor_name"`
|
||||
// 宿主页信息(由 widget.js / 前端上报)
|
||||
PageURL string `json:"page_url" form:"page_url"`
|
||||
PageTitle string `json:"page_title" form:"page_title"`
|
||||
Referrer string `json:"referrer" form:"referrer"`
|
||||
}
|
||||
|
||||
type WidgetPageViewReq struct {
|
||||
SessionID uint `json:"session_id" binding:"required"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
PageURL string `json:"page_url" binding:"required"`
|
||||
PageTitle string `json:"page_title"`
|
||||
}
|
||||
|
||||
type WidgetHeartbeatReq struct {
|
||||
SessionID uint `json:"session_id" binding:"required"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
}
|
||||
|
||||
type WidgetMessageReq struct {
|
||||
@@ -104,6 +120,20 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
}
|
||||
|
||||
visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c)
|
||||
pageURL := sanitizePageURL(req.PageURL)
|
||||
if pageURL == "" {
|
||||
// query 兜底
|
||||
pageURL = sanitizePageURL(c.Query("page_url"))
|
||||
}
|
||||
pageTitle := sanitizePageTitle(req.PageTitle)
|
||||
if pageTitle == "" {
|
||||
pageTitle = sanitizePageTitle(c.Query("page_title"))
|
||||
}
|
||||
referrer := sanitizePageURL(req.Referrer)
|
||||
if referrer == "" {
|
||||
referrer = sanitizePageURL(c.Query("referrer"))
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
// 创建会话
|
||||
session := model.Session{
|
||||
@@ -114,6 +144,12 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
VisitorIP: visitorIP,
|
||||
VisitorRegion: visitorRegion,
|
||||
UserAgent: userAgent,
|
||||
LandingURL: pageURL,
|
||||
LandingTitle: pageTitle,
|
||||
Referrer: referrer,
|
||||
CurrentURL: pageURL,
|
||||
CurrentTitle: pageTitle,
|
||||
LastSeenAt: &now,
|
||||
Status: "waiting",
|
||||
Priority: "normal",
|
||||
}
|
||||
@@ -121,6 +157,16 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"})
|
||||
return
|
||||
}
|
||||
// 首条浏览轨迹
|
||||
if pageURL != "" {
|
||||
_ = model.DB.Create(&model.VisitorPageView{
|
||||
SessionID: session.ID,
|
||||
TenantID: channel.TenantID,
|
||||
URL: pageURL,
|
||||
Title: pageTitle,
|
||||
EnteredAt: now,
|
||||
}).Error
|
||||
}
|
||||
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID).
|
||||
Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()})
|
||||
|
||||
@@ -197,10 +243,114 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
} else if canServeNow {
|
||||
resp["agent_name"] = agentNickname
|
||||
}
|
||||
resp["landing_url"] = session.LandingURL
|
||||
resp["current_url"] = session.CurrentURL
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||
}
|
||||
|
||||
// PageView POST /api/widget/pageview — 访客换页上报
|
||||
func (h *WidgetHandler) PageView(c *gin.Context) {
|
||||
var req WidgetPageViewReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
token := visitorTokenFromRequest(c, req.VisitorToken)
|
||||
session, ok := loadVisitorSession(c, req.SessionID, token)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"})
|
||||
return
|
||||
}
|
||||
|
||||
pageURL := sanitizePageURL(req.PageURL)
|
||||
if pageURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "页面地址无效"})
|
||||
return
|
||||
}
|
||||
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}})
|
||||
return
|
||||
}
|
||||
|
||||
pv := model.VisitorPageView{
|
||||
SessionID: session.ID,
|
||||
TenantID: session.TenantID,
|
||||
URL: pageURL,
|
||||
Title: pageTitle,
|
||||
EnteredAt: now,
|
||||
}
|
||||
if err := model.DB.Create(&pv).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "记录失败"})
|
||||
return
|
||||
}
|
||||
_ = model.DB.Model(session).Updates(map[string]interface{}{
|
||||
"current_url": pageURL,
|
||||
"current_title": pageTitle,
|
||||
"last_seen_at": now,
|
||||
}).Error
|
||||
|
||||
// 控制单会话条数
|
||||
var count int64
|
||||
model.DB.Model(&model.VisitorPageView{}).Where("session_id = ?", session.ID).Count(&count)
|
||||
if count > maxPageViewsPerSession {
|
||||
var oldest []model.VisitorPageView
|
||||
model.DB.Where("session_id = ?", session.ID).Order("entered_at asc").
|
||||
Limit(int(count - maxPageViewsPerSession)).Find(&oldest)
|
||||
ids := make([]uint, 0, len(oldest))
|
||||
for _, o := range oldest {
|
||||
ids = append(ids, o.ID)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
model.DB.Where("id IN ?", ids).Delete(&model.VisitorPageView{})
|
||||
}
|
||||
}
|
||||
|
||||
if payload, err := ws.NewEvent("page_view", session.ID, gin.H{
|
||||
"id": pv.ID,
|
||||
"session_id": pv.SessionID,
|
||||
"url": pv.URL,
|
||||
"title": pv.Title,
|
||||
"entered_at": pv.EnteredAt,
|
||||
}); err == nil {
|
||||
ws.DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv})
|
||||
}
|
||||
|
||||
// Heartbeat POST /api/widget/heartbeat — 刷新 last_seen,供在线读秒
|
||||
func (h *WidgetHandler) Heartbeat(c *gin.Context) {
|
||||
var req WidgetHeartbeatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
token := visitorTokenFromRequest(c, req.VisitorToken)
|
||||
session, ok := loadVisitorSession(c, req.SessionID, token)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "ended": true}})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_ = model.DB.Model(session).Update("last_seen_at", now).Error
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "last_seen_at": now}})
|
||||
}
|
||||
|
||||
func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
|
||||
if token := c.GetHeader("X-Visitor-Token"); token != "" {
|
||||
return token
|
||||
|
||||
@@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&Customer{},
|
||||
&CustomerTag{},
|
||||
&Session{},
|
||||
&VisitorPageView{},
|
||||
&Message{},
|
||||
&SessionEvent{},
|
||||
&Category{},
|
||||
|
||||
@@ -84,6 +84,14 @@ type Session struct {
|
||||
VisitorIP string `gorm:"size:64" json:"visitor_ip"`
|
||||
VisitorRegion string `gorm:"size:100" json:"visitor_region"`
|
||||
UserAgent string `gorm:"size:500" json:"user_agent"`
|
||||
// 落地页 / 当前页(访客浏览轨迹)
|
||||
LandingURL string `gorm:"size:1000" json:"landing_url"`
|
||||
LandingTitle string `gorm:"size:200" json:"landing_title"`
|
||||
Referrer string `gorm:"size:1000" json:"referrer"`
|
||||
CurrentURL string `gorm:"size:1000" json:"current_url"`
|
||||
CurrentTitle string `gorm:"size:200" json:"current_title"`
|
||||
// LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
|
||||
Status string `gorm:"size:20;default:waiting" json:"status"`
|
||||
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
||||
@@ -95,6 +103,16 @@ type Session struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VisitorPageView 访客在宿主站的页面浏览记录(按会话)。
|
||||
type VisitorPageView struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SessionID uint `gorm:"index;not null" json:"session_id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
URL string `gorm:"size:1000;not null" json:"url"`
|
||||
Title string `gorm:"size:200" json:"title"`
|
||||
EnteredAt time.Time `json:"entered_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SessionID uint `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"session_id"`
|
||||
|
||||
Reference in New Issue
Block a user