feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"kefu-cloud/server/internal/model"
|
||||
"kefu-cloud/server/internal/ws"
|
||||
)
|
||||
|
||||
const VisitorOnlineTimeout = 45 * time.Second
|
||||
|
||||
// VisitorIsOnline 根据最近心跳和显式离开时间动态判断访客是否在线。
|
||||
func VisitorIsOnline(session *model.Session, now time.Time) bool {
|
||||
if session == nil || session.Status == "ended" || session.Status == "archived" {
|
||||
return false
|
||||
}
|
||||
if session.LastSeenAt == nil {
|
||||
return false
|
||||
}
|
||||
if session.VisitorLeftAt != nil && !session.LastSeenAt.After(*session.VisitorLeftAt) {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*session.LastSeenAt) <= VisitorOnlineTimeout
|
||||
}
|
||||
|
||||
// UpdateVisitorPresence 处理 visible/hidden/leave 状态,并返回切换前后的在线状态。
|
||||
func UpdateVisitorPresence(session *model.Session, state string, now time.Time) (bool, bool, error) {
|
||||
wasOnline := VisitorIsOnline(session, now)
|
||||
updates := map[string]interface{}{}
|
||||
switch state {
|
||||
case "hidden", "leave":
|
||||
leftAt := now
|
||||
updates["visitor_left_at"] = leftAt
|
||||
session.VisitorLeftAt = &leftAt
|
||||
default:
|
||||
updates["last_seen_at"] = now
|
||||
updates["visitor_left_at"] = nil
|
||||
session.LastSeenAt = &now
|
||||
session.VisitorLeftAt = nil
|
||||
}
|
||||
if err := model.DB.Model(session).Updates(updates).Error; err != nil {
|
||||
return wasOnline, false, err
|
||||
}
|
||||
return wasOnline, VisitorIsOnline(session, now), nil
|
||||
}
|
||||
|
||||
func broadcastVisitorPresence(session *model.Session, online bool) {
|
||||
payload, err := ws.NewEvent("visitor_presence", session.ID, map[string]interface{}{
|
||||
"visitor_online": online,
|
||||
"last_seen_at": session.LastSeenAt,
|
||||
"visitor_left_at": session.VisitorLeftAt,
|
||||
})
|
||||
if err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kefu-cloud/server/internal/model"
|
||||
)
|
||||
|
||||
func TestVisitorIsOnlineByHeartbeatAndLeave(t *testing.T) {
|
||||
now := time.Now()
|
||||
recent := now.Add(-20 * time.Second)
|
||||
stale := now.Add(-VisitorOnlineTimeout - time.Second)
|
||||
left := now.Add(-10 * time.Second)
|
||||
|
||||
if !VisitorIsOnline(&model.Session{Status: "active", LastSeenAt: &recent}, now) {
|
||||
t.Fatal("最近有心跳的进行中会话应在线")
|
||||
}
|
||||
if VisitorIsOnline(&model.Session{Status: "active", LastSeenAt: &stale}, now) {
|
||||
t.Fatal("超过 45 秒无心跳应离线")
|
||||
}
|
||||
if VisitorIsOnline(&model.Session{Status: "active", LastSeenAt: &recent, VisitorLeftAt: &left}, now) {
|
||||
t.Fatal("显式离开后应离线")
|
||||
}
|
||||
afterReturn := now.Add(-5 * time.Second)
|
||||
if !VisitorIsOnline(&model.Session{Status: "active", LastSeenAt: &afterReturn, VisitorLeftAt: &left}, now) {
|
||||
t.Fatal("离开后重新心跳应恢复在线")
|
||||
}
|
||||
if VisitorIsOnline(&model.Session{Status: "ended", LastSeenAt: &recent}, now) {
|
||||
t.Fatal("已结束会话不应在线")
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type SessionListItem struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
ChannelDomain string `json:"channel_domain"`
|
||||
}
|
||||
|
||||
// AgentItem 可转接/筛选坐席项。
|
||||
@@ -60,6 +61,7 @@ func LoadTenantSession(id string, tenantID uint) (*model.Session, error) {
|
||||
if session.TenantID != tenantID {
|
||||
return nil, Forbidden("无权访问其他租户会话")
|
||||
}
|
||||
session.VisitorOnline = VisitorIsOnline(&session, time.Now())
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +227,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
|
||||
items := make([]SessionListItem, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]}
|
||||
item.VisitorOnline = VisitorIsOnline(&session, time.Now())
|
||||
if !actor.SessionAll && session.AgentID != nil && *session.AgentID == actor.UserID {
|
||||
item.UnreadCount = unreadCount(session)
|
||||
}
|
||||
@@ -239,6 +242,7 @@ func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListIte
|
||||
if ch, ok := channelMap[session.ChannelID]; ok {
|
||||
item.ChannelName = ch.Name
|
||||
item.ChannelType = ch.Type
|
||||
item.ChannelDomain = ch.SiteDomain
|
||||
}
|
||||
var lastMsg model.Message
|
||||
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
|
||||
@@ -529,7 +533,9 @@ func EndSession(session *model.Session, operatorID uint, reason string) error {
|
||||
|
||||
result := model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ? AND status <> ?", session.ID, session.TenantID, "ended").
|
||||
Updates(map[string]interface{}{"status": "ended", "end_reason": reason, "ended_at": now})
|
||||
Updates(map[string]interface{}{
|
||||
"status": "ended", "end_reason": reason, "ended_at": now, "visitor_left_at": now,
|
||||
})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return NotFound("会话不存在")
|
||||
@@ -543,6 +549,9 @@ func EndSession(session *model.Session, operatorID uint, reason string) error {
|
||||
})
|
||||
session.Status = "ended"
|
||||
session.EndedAt = &now
|
||||
session.VisitorLeftAt = &now
|
||||
session.VisitorOnline = false
|
||||
_ = CloseCurrentPageView(session, now)
|
||||
BroadcastSessionUpdate(session)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeSiteDomain 接受域名、主机名或完整 URL,统一为小写主机名。
|
||||
func NormalizeSiteDomain(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimSuffix(parsed.Hostname(), ".")
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if ip := net.ParseIP(host); ip == nil && !strings.Contains(host, ".") && host != "localhost" {
|
||||
return ""
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func PageHost(rawURL string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".")
|
||||
}
|
||||
|
||||
// SiteDomainMatches 同时兼容带 www 与不带 www 的主机名。
|
||||
func SiteDomainMatches(siteDomain, pageURL string) bool {
|
||||
expected := NormalizeSiteDomain(siteDomain)
|
||||
actual := PageHost(pageURL)
|
||||
if expected == "" || actual == "" {
|
||||
return expected == ""
|
||||
}
|
||||
return strings.TrimPrefix(expected, "www.") == strings.TrimPrefix(actual, "www.")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeSiteDomain(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://www.Example.com/path": "www.example.com",
|
||||
"shop.example.com:443": "shop.example.com",
|
||||
"localhost:5173": "localhost",
|
||||
"": "",
|
||||
"invalid host": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := NormalizeSiteDomain(input); got != want {
|
||||
t.Fatalf("NormalizeSiteDomain(%q)=%q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteDomainMatches(t *testing.T) {
|
||||
if !SiteDomainMatches("example.com", "https://www.example.com/product/1") {
|
||||
t.Fatal("www 与主域应视为同一站点")
|
||||
}
|
||||
if SiteDomainMatches("shop.example.com", "https://www.example.com") {
|
||||
t.Fatal("不同子域不应匹配")
|
||||
}
|
||||
if !SiteDomainMatches("", "https://any.example.com") {
|
||||
t.Fatal("未配置域名时应允许任意站点")
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,13 @@ var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
|
||||
// VisitorMeta 访客环境信息(由 handler 从请求解析,service 不感知 HTTP)。
|
||||
type VisitorMeta struct {
|
||||
IP string
|
||||
Region string
|
||||
UserAgent string
|
||||
IP string
|
||||
Region string
|
||||
Country string
|
||||
Province string
|
||||
City string
|
||||
UserAgent string
|
||||
DeviceLabel string
|
||||
}
|
||||
|
||||
// WidgetSession 校验访客凭证并加载会话。
|
||||
@@ -57,6 +61,9 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
First(&channel).Error; err != nil {
|
||||
return nil, NotFound("渠道不存在或已关闭")
|
||||
}
|
||||
if channel.SiteDomain != "" && !SiteDomainMatches(channel.SiteDomain, in.PageURL) {
|
||||
return nil, Forbidden("渠道与当前网站域名不匹配")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(in.VisitorName)
|
||||
if name == "" {
|
||||
@@ -104,6 +111,10 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
VisitorTokenHash: visitorTokenHash,
|
||||
VisitorIP: in.Meta.IP,
|
||||
VisitorRegion: in.Meta.Region,
|
||||
VisitorCountry: in.Meta.Country,
|
||||
VisitorProvince: in.Meta.Province,
|
||||
VisitorCity: in.Meta.City,
|
||||
SiteHost: PageHost(in.PageURL),
|
||||
UserAgent: in.Meta.UserAgent,
|
||||
DeviceKey: deviceKey,
|
||||
LandingURL: in.PageURL,
|
||||
@@ -171,6 +182,7 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
"online_agent_count": onlineCount,
|
||||
"within_work_hours": withinWorkHours,
|
||||
"session_status": session.Status,
|
||||
"visitor_online": true,
|
||||
"welcome_message": replyConfig.WelcomeMessage,
|
||||
"welcome_messages": replyConfig.WelcomeMessages,
|
||||
"display_name": replyConfig.DisplayName,
|
||||
@@ -200,11 +212,13 @@ func WidgetPageView(session *model.Session, pageURL, pageTitle string) (interfac
|
||||
return nil, BadRequest("页面地址无效")
|
||||
}
|
||||
now := time.Now()
|
||||
wasOnline := VisitorIsOnline(session, now)
|
||||
|
||||
// 与当前页相同:只刷新活跃时间/标题,不插重复轨迹
|
||||
if strings.TrimSpace(session.CurrentURL) == pageURL {
|
||||
if strings.TrimSpace(session.CurrentURL) == pageURL && CurrentPageViewOpen(session.ID, pageURL) {
|
||||
updates := map[string]interface{}{
|
||||
"last_seen_at": now,
|
||||
"last_seen_at": now,
|
||||
"visitor_left_at": nil,
|
||||
}
|
||||
titleFixed := false
|
||||
if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) {
|
||||
@@ -228,9 +242,15 @@ func WidgetPageView(session *model.Session, pageURL, pageTitle string) (interfac
|
||||
}
|
||||
}
|
||||
_ = model.DB.Model(session).Updates(updates).Error
|
||||
session.LastSeenAt = &now
|
||||
session.VisitorLeftAt = nil
|
||||
if !wasOnline {
|
||||
broadcastVisitorPresence(session, true)
|
||||
}
|
||||
return map[string]interface{}{"deduped": true, "title_updated": titleFixed}, nil
|
||||
}
|
||||
|
||||
_ = CloseCurrentPageView(session, now)
|
||||
pv := model.VisitorPageView{
|
||||
SessionID: session.ID,
|
||||
TenantID: session.TenantID,
|
||||
@@ -242,10 +262,20 @@ func WidgetPageView(session *model.Session, pageURL, pageTitle string) (interfac
|
||||
return nil, Internal("记录失败")
|
||||
}
|
||||
_ = model.DB.Model(session).Updates(map[string]interface{}{
|
||||
"current_url": pageURL,
|
||||
"current_title": pageTitle,
|
||||
"last_seen_at": now,
|
||||
"current_url": pageURL,
|
||||
"current_title": pageTitle,
|
||||
"site_host": PageHost(pageURL),
|
||||
"last_seen_at": now,
|
||||
"visitor_left_at": nil,
|
||||
}).Error
|
||||
session.CurrentURL = pageURL
|
||||
session.CurrentTitle = pageTitle
|
||||
session.SiteHost = PageHost(pageURL)
|
||||
session.LastSeenAt = &now
|
||||
session.VisitorLeftAt = nil
|
||||
if !wasOnline {
|
||||
broadcastVisitorPresence(session, true)
|
||||
}
|
||||
|
||||
// 控制单会话条数
|
||||
var count int64
|
||||
@@ -278,14 +308,55 @@ func WidgetPageView(session *model.Session, pageURL, pageTitle string) (interfac
|
||||
return pv, nil
|
||||
}
|
||||
|
||||
// WidgetHeartbeat 刷新访客 last_seen;会话已结束时仅返回结束标记。
|
||||
func WidgetHeartbeat(session *model.Session) (ended bool, lastSeen *time.Time, err error) {
|
||||
// CurrentPageViewOpen 判断当前 URL 是否仍有一条未关闭的轨迹。
|
||||
func CurrentPageViewOpen(sessionID uint, pageURL string) bool {
|
||||
var current model.VisitorPageView
|
||||
err := model.DB.Where("session_id = ? AND url = ?", sessionID, pageURL).
|
||||
Order("entered_at desc").First(¤t).Error
|
||||
return err == nil && current.ExitedAt == nil
|
||||
}
|
||||
|
||||
// CloseCurrentPageView 关闭当前页轨迹并累计停留时长。
|
||||
func CloseCurrentPageView(session *model.Session, now time.Time) error {
|
||||
if session == nil || session.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
var current model.VisitorPageView
|
||||
err := model.DB.Where("session_id = ? AND exited_at IS NULL", session.ID).
|
||||
Order("entered_at desc").First(¤t).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
duration := int(now.Sub(current.EnteredAt).Seconds())
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
return model.DB.Model(¤t).Updates(map[string]interface{}{
|
||||
"exited_at": now,
|
||||
"duration_seconds": duration,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// WidgetHeartbeat 处理访客 presence 心跳;会话已结束时仅返回结束标记。
|
||||
func WidgetHeartbeat(session *model.Session, state string) (ended bool, lastSeen *time.Time, online bool, err error) {
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
return true, nil, nil
|
||||
return true, nil, false, nil
|
||||
}
|
||||
now := time.Now()
|
||||
_ = model.DB.Model(session).Update("last_seen_at", now).Error
|
||||
return false, &now, nil
|
||||
wasOnline, isOnline, updateErr := UpdateVisitorPresence(session, state, now)
|
||||
if updateErr != nil {
|
||||
return false, nil, false, Internal("更新访客状态失败")
|
||||
}
|
||||
if wasOnline != isOnline {
|
||||
broadcastVisitorPresence(session, isOnline)
|
||||
}
|
||||
if state == "leave" {
|
||||
_ = CloseCurrentPageView(session, now)
|
||||
}
|
||||
return false, session.LastSeenAt, isOnline, nil
|
||||
}
|
||||
|
||||
// WidgetSendMessage 访客发送消息;排队中会尝试自动分配并广播。
|
||||
|
||||
Reference in New Issue
Block a user