feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user