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
+8
View File
@@ -13,6 +13,7 @@ type Config struct {
JWT JWTConfig
Storage StorageConfig
Redis RedisConfig
GeoIP GeoIPConfig
AllowedOrigins []string
}
@@ -61,6 +62,10 @@ type RedisConfig struct {
DB int
}
type GeoIPConfig struct {
DBPath string
}
func Load() *Config {
return &Config{
Server: ServerConfig{
@@ -109,6 +114,9 @@ func Load() *Config {
Password: getEnv("REDIS_PASSWORD", ""),
DB: getEnvInt("REDIS_DB", 0),
},
GeoIP: GeoIPConfig{
DBPath: getEnv("GEOIP_DB_PATH", ""),
},
}
}
+136
View File
@@ -0,0 +1,136 @@
package geoip
import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/oschwald/geoip2-golang"
)
const cacheTTL = 30 * time.Minute
type Location struct {
CountryCode string
Country string
Province string
City string
Region string
}
type cacheEntry struct {
location Location
found bool
expireAt time.Time
}
var (
reader *geoip2.Reader
cacheMu sync.Mutex
cache = map[string]cacheEntry{}
cacheCap = 10_000
)
// Init opens an optional MaxMind GeoLite2/GeoIP2 City database.
func Init(path string) error {
Close()
path = strings.TrimSpace(path)
if path == "" {
return nil
}
opened, err := geoip2.Open(path)
if err != nil {
return fmt.Errorf("打开 GeoIP 数据库失败: %w", err)
}
reader = opened
return nil
}
func Close() {
if reader != nil {
_ = reader.Close()
reader = nil
}
cacheMu.Lock()
cache = map[string]cacheEntry{}
cacheMu.Unlock()
}
func Enabled() bool {
return reader != nil
}
func Lookup(ip string) (Location, bool) {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil || reader == nil {
return Location{}, false
}
now := time.Now()
cacheMu.Lock()
if cached, ok := cache[ip]; ok && now.Before(cached.expireAt) {
cacheMu.Unlock()
return cached.location, cached.found
}
cacheMu.Unlock()
city, err := reader.City(parsed)
if err != nil {
return Location{}, false
}
location := Location{
CountryCode: strings.ToUpper(city.Country.IsoCode),
Country: localizedName(city.Country.Names),
City: localizedName(city.City.Names),
}
if len(city.Subdivisions) > 0 {
location.Province = localizedName(city.Subdivisions[len(city.Subdivisions)-1].Names)
}
location.Region = buildRegion(location)
found := location.Country != "" || location.Province != "" || location.City != ""
cacheMu.Lock()
if len(cache) >= cacheCap {
for key, item := range cache {
if now.After(item.expireAt) {
delete(cache, key)
}
}
}
if len(cache) < cacheCap {
cache[ip] = cacheEntry{location: location, found: found, expireAt: now.Add(cacheTTL)}
}
cacheMu.Unlock()
return location, found
}
func localizedName(names map[string]string) string {
for _, key := range []string{"zh-CN", "zh", "en"} {
if value := strings.TrimSpace(names[key]); value != "" {
return value
}
}
for _, value := range names {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func buildRegion(location Location) string {
parts := make([]string, 0, 3)
for _, value := range []string{location.Country, location.Province, location.City} {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if len(parts) > 0 && parts[len(parts)-1] == value {
continue
}
parts = append(parts, value)
}
return strings.Join(parts, " ")
}
+21
View File
@@ -0,0 +1,21 @@
package geoip
import "testing"
func TestBuildRegion(t *testing.T) {
got := buildRegion(Location{Country: "中国", Province: "广东省", City: "深圳市"})
if got != "中国 广东省 深圳市" {
t.Fatalf("buildRegion=%q", got)
}
got = buildRegion(Location{Country: "中国", Province: "中国", City: ""})
if got != "中国" {
t.Fatalf("重复地区应去重: %q", got)
}
}
func TestLocalizedNamePrefersChinese(t *testing.T) {
got := localizedName(map[string]string{"en": "China", "zh-CN": "中国"})
if got != "中国" {
t.Fatalf("应优先中文名: %q", got)
}
}
+22
View File
@@ -0,0 +1,22 @@
package geoip
import (
"os"
"testing"
)
func TestLookupWithConfiguredDatabase(t *testing.T) {
path := os.Getenv("GEOIP_TEST_DB")
if path == "" {
t.Skip("未设置 GEOIP_TEST_DB")
}
if err := Init(path); err != nil {
t.Fatal(err)
}
defer Close()
location, ok := Lookup("8.8.8.8")
if !ok || location.Country == "" {
t.Fatalf("GeoIP 查询失败: %+v ok=%v", location, ok)
}
}
+34 -14
View File
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/service"
)
type ChannelHandler struct{}
@@ -28,6 +29,7 @@ type channelView struct {
Config string `json:"config"`
ScriptCode string `json:"script_code"`
ChannelKey string `json:"channel_key"`
SiteDomain string `json:"site_domain"`
}
func extractChannelKey(script string) string {
@@ -54,7 +56,7 @@ func toChannelView(ch model.Channel) channelView {
return channelView{
ID: ch.ID, TenantID: ch.TenantID, Type: ch.Type, Name: ch.Name,
Status: ch.Status, Config: ch.Config, ScriptCode: ch.ScriptCode,
ChannelKey: extractChannelKey(ch.ScriptCode),
ChannelKey: extractChannelKey(ch.ScriptCode), SiteDomain: ch.SiteDomain,
}
}
@@ -88,8 +90,9 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
type updateChannelReq struct {
Name *string `json:"name"`
Status *string `json:"status"`
Name *string `json:"name"`
Status *string `json:"status"`
SiteDomain *string `json:"site_domain"`
}
func (h *ChannelHandler) Update(c *gin.Context) {
@@ -128,6 +131,14 @@ func (h *ChannelHandler) Update(c *gin.Context) {
}
updates["status"] = status
}
if req.SiteDomain != nil {
domain := service.NormalizeSiteDomain(*req.SiteDomain)
if strings.TrimSpace(*req.SiteDomain) != "" && domain == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "网站域名格式无效"})
return
}
updates["site_domain"] = domain
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
@@ -142,8 +153,9 @@ func (h *ChannelHandler) Update(c *gin.Context) {
}
type createChannelReq struct {
Type string `json:"type" binding:"required"`
Name string `json:"name"`
Type string `json:"type" binding:"required"`
Name string `json:"name"`
SiteDomain string `json:"site_domain"`
}
func (h *ChannelHandler) Create(c *gin.Context) {
@@ -166,11 +178,13 @@ func (h *ChannelHandler) Create(c *gin.Context) {
return
}
tenantID := middleware.GetTenantID(c)
var exists int64
model.DB.Model(&model.Channel{}).Where("tenant_id = ? AND type = ?", tenantID, channelType).Count(&exists)
if exists > 0 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该类型渠道已存在"})
return
if channelType != "web" {
var exists int64
model.DB.Model(&model.Channel{}).Where("tenant_id = ? AND type = ?", tenantID, channelType).Count(&exists)
if exists > 0 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该类型渠道已存在"})
return
}
}
name := strings.TrimSpace(req.Name)
@@ -183,12 +197,18 @@ func (h *ChannelHandler) Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成渠道标识失败"})
return
}
siteDomain := service.NormalizeSiteDomain(req.SiteDomain)
if strings.TrimSpace(req.SiteDomain) != "" && siteDomain == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "网站域名格式无效"})
return
}
channel := model.Channel{
TenantID: tenantID,
Type: channelType,
Name: name,
Status: "disabled",
TenantID: tenantID,
Type: channelType,
Name: name,
SiteDomain: siteDomain,
Status: "disabled",
}
if channelType == "web" {
channel.Status = "enabled"
@@ -21,6 +21,7 @@ import (
"kefu-cloud/server/internal/handler"
"kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/service"
"kefu-cloud/server/internal/storage"
)
@@ -638,6 +639,156 @@ func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
}
}
func TestMultipleWebChannelsBindToDomains(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "多网站租户", "normal")
admin := createUser(t, tenant.ID, "multi-site-admin", "admin")
createSite := func(name, domain string) struct {
ID uint `json:"id"`
ScriptCode string `json:"script_code"`
ChannelKey string `json:"channel_key"`
SiteDomain string `json:"site_domain"`
} {
rec := httptest.NewRecorder()
body := fmt.Sprintf(`{"type":"web","name":%q,"site_domain":%q}`, name, domain)
router.ServeHTTP(rec, bearerRequest(t, http.MethodPost, "/api/channels", []byte(body), admin))
if rec.Code != http.StatusOK {
t.Fatalf("创建网站渠道失败: %d %s", rec.Code, rec.Body.String())
}
var response struct {
Data struct {
ID uint `json:"id"`
ScriptCode string `json:"script_code"`
ChannelKey string `json:"channel_key"`
SiteDomain string `json:"site_domain"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("解析渠道响应失败: %v", err)
}
if response.Data.SiteDomain != domain {
t.Fatalf("渠道域名未保存: %+v", response.Data)
}
return response.Data
}
shop := createSite("商城", "shop.example.com")
support := createSite("帮助中心", "help.example.com")
if shop.ChannelKey == support.ChannelKey {
t.Fatal("不同网站渠道必须使用不同 Key")
}
wrongReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
bytes.NewBufferString(fmt.Sprintf(`{"channel_key":%q,"page_url":"https://help.example.com/faq"}`, shop.ChannelKey)))
wrongReq.Header.Set("Content-Type", "application/json")
wrongRec := httptest.NewRecorder()
router.ServeHTTP(wrongRec, wrongReq)
if wrongRec.Code != http.StatusForbidden {
t.Fatalf("跨网站使用渠道 Key 应拒绝: %d %s", wrongRec.Code, wrongRec.Body.String())
}
rightReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
bytes.NewBufferString(fmt.Sprintf(`{"channel_key":%q,"page_url":"https://shop.example.com/product/1"}`, shop.ChannelKey)))
rightReq.Header.Set("Content-Type", "application/json")
rightRec := httptest.NewRecorder()
router.ServeHTTP(rightRec, rightReq)
if rightRec.Code != http.StatusOK {
t.Fatalf("同域初始化失败: %d %s", rightRec.Code, rightRec.Body.String())
}
}
func TestWidgetHeartbeatUpdatesVisitorOnline(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "在线状态租户", "normal")
admin := createUser(t, tenant.ID, "presence-admin", "admin")
channel := model.Channel{
TenantID: tenant.ID, Type: "web", Name: "官网", Status: "enabled",
ScriptCode: `<script src="/widget.js" data-id="WK_presence_001"></script>`,
}
if err := model.DB.Create(&channel).Error; err != nil {
t.Fatal(err)
}
initRec := httptest.NewRecorder()
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init",
bytes.NewBufferString(`{"channel_key":"WK_presence_001","page_url":"https://example.com/"}`))
initReq.Header.Set("Content-Type", "application/json")
router.ServeHTTP(initRec, initReq)
if initRec.Code != http.StatusOK {
t.Fatalf("初始化访客失败: %s", initRec.Body.String())
}
var initResp struct {
Data struct {
SessionID uint `json:"session_id"`
VisitorToken string `json:"visitor_token"`
} `json:"data"`
}
if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil {
t.Fatal(err)
}
heartbeat := func(state string) {
body := fmt.Sprintf(`{"session_id":%d,"visitor_token":%q,"state":%q}`, initResp.Data.SessionID, initResp.Data.VisitorToken, state)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/widget/heartbeat", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("心跳 %s 失败: %s", state, rec.Body.String())
}
}
heartbeat("hidden")
var hidden model.Session
if err := model.DB.First(&hidden, initResp.Data.SessionID).Error; err != nil {
t.Fatal(err)
}
if hidden.VisitorLeftAt == nil || hidden.LastSeenAt == nil || !hidden.LastSeenAt.Before(*hidden.VisitorLeftAt) {
t.Fatalf("隐藏页面应记录离开时间: %+v", hidden)
}
heartbeat("visible")
var visible model.Session
if err := model.DB.First(&visible, initResp.Data.SessionID).Error; err != nil {
t.Fatal(err)
}
if visible.VisitorLeftAt != nil || !service.VisitorIsOnline(&visible, time.Now()) {
t.Fatalf("恢复可见后应重新在线: %+v", visible)
}
pageBody := fmt.Sprintf(`{"session_id":%d,"visitor_token":%q,"page_url":"https://example.com/next","page_title":"下一页"}`,
initResp.Data.SessionID, initResp.Data.VisitorToken)
pageRec := httptest.NewRecorder()
pageReq := httptest.NewRequest(http.MethodPost, "/api/widget/pageview", bytes.NewBufferString(pageBody))
pageReq.Header.Set("Content-Type", "application/json")
router.ServeHTTP(pageRec, pageReq)
if pageRec.Code != http.StatusOK {
t.Fatalf("页面轨迹更新失败: %s", pageRec.Body.String())
}
var pageViews []model.VisitorPageView
if err := model.DB.Where("session_id = ?", initResp.Data.SessionID).Order("entered_at asc").Find(&pageViews).Error; err != nil {
t.Fatal(err)
}
if len(pageViews) != 2 || pageViews[0].ExitedAt == nil {
t.Fatalf("页面切换应关闭上一页轨迹: %+v", pageViews)
}
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, bearerRequest(t, http.MethodGet, "/api/sessions?page=1&pageSize=10", nil, admin))
if listRec.Code != http.StatusOK {
t.Fatalf("读取会话列表失败: %s", listRec.Body.String())
}
var listResp struct {
List []model.Session `json:"list"`
}
if err := json.Unmarshal(listRec.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
if len(listResp.List) != 1 || !listResp.List[0].VisitorOnline {
t.Fatalf("会话列表应返回访客在线状态: %+v", listResp.List)
}
}
func TestCustomRoleCodeIsStableAndInvalidPermissionUpdateIsAtomic(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "自定义角色租户", "normal")
+16 -11
View File
@@ -392,18 +392,27 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
return
}
channelTypes := make(map[uint]string)
channelByID := make(map[uint]model.Channel)
for _, channel := range channels {
channelTypes[channel.ID] = channel.Type
channelByID[channel.ID] = channel
}
labels := map[string]string{"web": "网页", "wechat": "微信", "app": "APP", "phone": "电话工单", "email": "邮件"}
counts := make(map[string]int)
for _, session := range sessions {
channelType := channelTypes[session.ChannelID]
if channelType == "" {
channelType = "unknown"
label := "未知渠道"
if channel, ok := channelByID[session.ChannelID]; ok {
label = strings.TrimSpace(channel.Name)
if label == "" {
label = labels[channel.Type]
}
if label == "" {
label = channel.Type
}
}
counts[channelType]++
if siteHost := strings.TrimSpace(session.SiteHost); siteHost != "" {
label += " · " + siteHost
}
counts[label]++
}
keys := make([]string, 0, len(counts))
for key := range counts {
@@ -412,11 +421,7 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
sort.Strings(keys)
data := make([]gin.H, 0, len(keys))
for _, key := range keys {
label := labels[key]
if label == "" {
label = key
}
data = append(data, gin.H{"type": label, "value": counts[key]})
data = append(data, gin.H{"type": key, "value": counts[key]})
}
middleware.JSON(c, data)
}
+46 -20
View File
@@ -5,6 +5,8 @@ import (
"strings"
"github.com/gin-gonic/gin"
"kefu-cloud/server/internal/geoip"
"kefu-cloud/server/internal/service"
)
// resolveVisitorIP 优先信任反代头,回落到 Gin ClientIP。
@@ -46,9 +48,27 @@ func resolveVisitorIP(c *gin.Context) string {
return ip
}
// resolveVisitorRegion 基于 IP 与常见 CDN 国家头做粗粒度地区,无需第三方库。
// 私网/本机显示「内网」;有国家头则映射中文名;否则「未知」。
func resolveVisitorRegion(c *gin.Context, ip string) string {
type visitorLocation struct {
Country string
Province string
City string
Region string
}
// resolveVisitorLocation 优先使用离线 GeoIP,未配置时降级到 CDN 国家头。
func resolveVisitorLocation(c *gin.Context, ip string) visitorLocation {
parsed := net.ParseIP(ip)
if parsed != nil &&
(parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast()) {
return visitorLocation{Region: "内网"}
}
if location, ok := geoip.Lookup(ip); ok {
return visitorLocation{
Country: location.Country, Province: location.Province,
City: location.City, Region: location.Region,
}
}
// Cloudflare / 部分网关国家码
country := strings.ToUpper(strings.TrimSpace(c.GetHeader("CF-IPCountry")))
if country == "" {
@@ -59,20 +79,15 @@ func resolveVisitorRegion(c *gin.Context, ip string) string {
}
if country != "" && country != "XX" && country != "T1" {
if name, ok := countryNameCN(country); ok {
return name
return visitorLocation{Country: name, Region: name}
}
return country
return visitorLocation{Country: country, Region: country}
}
return visitorLocation{Region: "未知"}
}
parsed := net.ParseIP(ip)
if parsed == nil {
return "未知"
}
if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() {
return "内网"
}
// 未接入 GeoIP 库时,公网 IP 地区暂标未知(可后续接入离线库)
return "未知"
func resolveVisitorRegion(c *gin.Context, ip string) string {
return resolveVisitorLocation(c, ip).Region
}
func countryNameCN(code string) (string, bool) {
@@ -123,13 +138,24 @@ func summarizeUserAgent(ua string) string {
return browser + " / " + osName
}
func captureVisitorMeta(c *gin.Context) (ip, region, ua, device string) {
ip = resolveVisitorIP(c)
region = resolveVisitorRegion(c, ip)
ua = c.Request.UserAgent()
func captureVisitorMeta(c *gin.Context) service.VisitorMeta {
ip := resolveVisitorIP(c)
location := resolveVisitorLocation(c, ip)
clip := func(value string, max int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) > max {
return string(runes[:max])
}
return value
}
ua := c.Request.UserAgent()
if len(ua) > 500 {
ua = ua[:500]
}
device = summarizeUserAgent(ua)
return
return service.VisitorMeta{
IP: ip, Region: clip(location.Region, 100), Country: clip(location.Country, 100),
Province: clip(location.Province, 100), City: clip(location.City, 100),
UserAgent: ua, DeviceLabel: summarizeUserAgent(ua),
}
}
+7 -8
View File
@@ -36,6 +36,7 @@ type WidgetPageViewReq struct {
type WidgetHeartbeatReq struct {
SessionID uint `json:"session_id" binding:"required"`
VisitorToken string `json:"visitor_token"`
State string `json:"state"`
}
type WidgetMessageReq struct {
@@ -91,7 +92,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
referrer = service.SanitizePageURL(c.Query("referrer"))
}
ip, region, userAgent, _ := captureVisitorMeta(c)
meta := captureVisitorMeta(c)
resp, err := service.WidgetInit(service.WidgetInitInput{
ChannelKey: req.ChannelKey,
VisitorName: req.VisitorName,
@@ -99,11 +100,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
PageURL: pageURL,
PageTitle: pageTitle,
Referrer: referrer,
Meta: service.VisitorMeta{
IP: ip,
Region: region,
UserAgent: userAgent,
},
Meta: meta,
})
if err != nil {
respondServiceError(c, err)
@@ -144,7 +141,7 @@ func (h *WidgetHandler) Heartbeat(c *gin.Context) {
respondServiceError(c, err)
return
}
ended, lastSeen, err := service.WidgetHeartbeat(session)
ended, lastSeen, online, err := service.WidgetHeartbeat(session, req.State)
if err != nil {
respondServiceError(c, err)
return
@@ -153,7 +150,9 @@ func (h *WidgetHandler) Heartbeat(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "ended": true}})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "last_seen_at": lastSeen}})
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{
"ok": true, "last_seen_at": lastSeen, "visitor_online": online,
}})
}
func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
+17 -6
View File
@@ -39,6 +39,7 @@ type Channel struct {
TenantID uint `gorm:"index;not null" json:"tenant_id"`
Type string `gorm:"size:30;not null" json:"type"`
Name string `gorm:"size:50" json:"name"`
SiteDomain string `gorm:"size:255" json:"site_domain"`
Status string `gorm:"size:20;default:enabled" json:"status"`
Config string `gorm:"type:text" json:"config"`
ScriptCode string `gorm:"size:500" json:"script_code"`
@@ -103,6 +104,10 @@ type Session struct {
VisitorTokenHash string `gorm:"size:64;index" json:"-"`
VisitorIP string `gorm:"size:64" json:"visitor_ip"`
VisitorRegion string `gorm:"size:100" json:"visitor_region"`
VisitorCountry string `gorm:"size:100" json:"visitor_country"`
VisitorProvince string `gorm:"size:100" json:"visitor_province"`
VisitorCity string `gorm:"size:100" json:"visitor_city"`
SiteHost string `gorm:"size:255" json:"site_host"`
UserAgent string `gorm:"size:500" json:"user_agent"`
// DeviceKey 访客端持久设备指纹(localStorage),用于设备级拉黑
DeviceKey string `gorm:"size:64;index" json:"device_key"`
@@ -114,6 +119,10 @@ type Session struct {
CurrentTitle string `gorm:"size:200" json:"current_title"`
// LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态
LastSeenAt *time.Time `json:"last_seen_at"`
// VisitorLeftAt 显式离开时间;再次心跳会清除并恢复在线。
VisitorLeftAt *time.Time `json:"visitor_left_at"`
// VisitorOnline 不入库,由 LastSeenAt/VisitorLeftAt 动态计算。
VisitorOnline bool `gorm:"-" json:"visitor_online"`
// DraftText 访客输入框未发送草稿(实时监控用,非聊天消息)
DraftText string `gorm:"type:text" json:"draft_text"`
DraftUpdatedAt *time.Time `json:"draft_updated_at"`
@@ -141,12 +150,14 @@ type CustomerContact struct {
// 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"`
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"`
ExitedAt *time.Time `json:"exited_at"`
Duration int `gorm:"column:duration_seconds" json:"duration_seconds"`
}
type Message struct {
+56
View File
@@ -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)
}
}
+32
View File
@@ -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("已结束会话不应在线")
}
}
+10 -1
View File
@@ -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
+48
View File
@@ -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.")
}
+30
View File
@@ -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("未配置域名时应允许任意站点")
}
}
+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 访客发送消息;排队中会尝试自动分配并广播。