diff --git a/server/internal/handler/page_track.go b/server/internal/handler/page_track.go new file mode 100644 index 0000000..3071029 --- /dev/null +++ b/server/internal/handler/page_track.go @@ -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) +} diff --git a/server/internal/handler/page_track_test.go b/server/internal/handler/page_track_test.go new file mode 100644 index 0000000..e421817 --- /dev/null +++ b/server/internal/handler/page_track_test.go @@ -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)) + } +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 7b26994..50440db 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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("") diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 71b3e3f..dcfa917 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -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, }) diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 7583ea0..f8e8b6c 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -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 diff --git a/server/internal/model/db.go b/server/internal/model/db.go index 9586619..13c2c03 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error { &Customer{}, &CustomerTag{}, &Session{}, + &VisitorPageView{}, &Message{}, &SessionEvent{}, &Category{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 0ecadb5..bd21cb1 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -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"` diff --git a/web/public/widget.js b/web/public/widget.js index 96fc89f..b196b06 100644 --- a/web/public/widget.js +++ b/web/public/widget.js @@ -1,6 +1,8 @@ /** * 客服云访客 Widget 嵌入脚本 * 用法: + * + * 向 iframe 同步宿主页 URL / 标题,并监听 SPA 路由变化,供客服端展示落地页与浏览轨迹。 */ (function () { if (window.__KEFU_WIDGET_LOADED__) return; @@ -28,6 +30,8 @@ var open = false; var iframe = null; + var lastSentURL = ''; + var lastSentAt = 0; var btn = document.createElement('button'); btn.type = 'button'; @@ -51,13 +55,56 @@ 'display:none', 'background:#fff', ].join(';'); + function hostPageInfo() { + var href = ''; + var title = ''; + var ref = ''; + try { + href = String(window.location.href || ''); + title = String(document.title || ''); + ref = String(document.referrer || ''); + } catch (e) { /* ignore */ } + return { url: href, title: title, referrer: ref }; + } + + function buildEmbedURL() { + var info = hostPageInfo(); + var q = + 'channel_key=' + encodeURIComponent(channelKey) + + '&embedded=1' + + '&page_url=' + encodeURIComponent(info.url) + + '&page_title=' + encodeURIComponent(info.title) + + '&referrer=' + encodeURIComponent(info.referrer); + return base + '/widget/embed?' + q; + } + + function postPageToIframe(force) { + if (!iframe || !iframe.contentWindow) return; + var info = hostPageInfo(); + var now = Date.now(); + if (!force && info.url === lastSentURL && now - lastSentAt < 800) return; + lastSentURL = info.url; + lastSentAt = now; + try { + iframe.contentWindow.postMessage({ + type: 'kefu-host-page', + url: info.url, + title: info.title, + referrer: info.referrer, + }, '*'); + } catch (e) { /* ignore */ } + } + function ensureIframe() { if (iframe) return; iframe = document.createElement('iframe'); iframe.title = '在线客服'; iframe.allow = 'clipboard-write'; iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;'; - iframe.src = base + '/widget/embed?channel_key=' + encodeURIComponent(channelKey) + '&embedded=1'; + iframe.src = buildEmbedURL(); + iframe.addEventListener('load', function () { + postPageToIframe(true); + }); panel.appendChild(iframe); } @@ -67,6 +114,7 @@ ensureIframe(); panel.style.display = 'block'; btn.style.display = 'none'; + postPageToIframe(true); } else { panel.style.display = 'none'; btn.style.display = 'flex'; @@ -80,8 +128,45 @@ if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') { setOpen(false); } + // iframe 就绪后可再次同步宿主页 + if (event.data.type === 'kefu-widget-ready') { + postPageToIframe(true); + } }); + // —— SPA / 浏览器导航监听 —— + function onRouteMaybeChanged() { + if (!open) return; + postPageToIframe(false); + } + + try { + var _push = history.pushState; + var _replace = history.replaceState; + history.pushState = function () { + var r = _push.apply(this, arguments); + onRouteMaybeChanged(); + return r; + }; + history.replaceState = function () { + var r = _replace.apply(this, arguments); + onRouteMaybeChanged(); + return r; + }; + } catch (e) { /* ignore */ } + + window.addEventListener('popstate', onRouteMaybeChanged); + window.addEventListener('hashchange', onRouteMaybeChanged); + // 标题可能异步更新 + try { + var titleEl = document.querySelector('title'); + if (titleEl && typeof MutationObserver !== 'undefined') { + new MutationObserver(function () { onRouteMaybeChanged(); }).observe(titleEl, { + subtree: true, characterData: true, childList: true, + }); + } + } catch (e) { /* ignore */ } + function mount() { document.body.appendChild(btn); document.body.appendChild(panel); diff --git a/web/src/pages/WidgetEmbed.tsx b/web/src/pages/WidgetEmbed.tsx index f337796..e669d46 100644 --- a/web/src/pages/WidgetEmbed.tsx +++ b/web/src/pages/WidgetEmbed.tsx @@ -7,6 +7,11 @@ const WidgetEmbed = () => { const [params] = useSearchParams() const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params]) const embedded = params.get('embedded') === '1' + const initialPage = useMemo(() => ({ + url: params.get('page_url') || undefined, + title: params.get('page_title') || undefined, + referrer: params.get('referrer') || undefined, + }), [params]) return (