访客落地页、在线读秒与浏览轨迹
记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user