feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
CI / Frontend (typecheck + lint + build) (push) Successful in 2m5s
CI / Backend (go vet + test) (push) Failing after 5m20s

This commit is contained in:
yml2213
2026-11-06 18:21:30 +08:00
parent 24fac1bcf1
commit 38d6fc1684
37 changed files with 1071 additions and 123 deletions
+84 -13
View File
@@ -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(&current).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(&current).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(&current).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 访客发送消息;排队中会尝试自动分配并广播。