From 93ef9f2040ccac2bb8af17b11f74ad8887b58c9e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 18:51:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(widget):=20=E4=BC=98=E5=8C=96=E8=AE=BF?= =?UTF-8?q?=E5=AE=A2=E5=9C=A8=E7=BA=BF=E7=BB=AD=E6=8E=A5=E4=B8=8E=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=9C=B0=E5=8C=BA=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/cmd/main.go | 11 +++ server/internal/service/visitor_geo.go | 74 +++++++++++++++++++++ server/internal/service/visitor_geo_test.go | 66 ++++++++++++++++++ web/public/widget.js | 25 +++++-- web/src/pages/agent/dashboard/helpers.ts | 5 +- web/src/widgets/VisitorChat.tsx | 7 +- 6 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 server/internal/service/visitor_geo.go create mode 100644 server/internal/service/visitor_geo_test.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 18d5da9..7ca08f9 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -13,6 +13,7 @@ import ( "kefu-cloud/server/internal/metrics" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/service" "kefu-cloud/server/internal/storage" "kefu-cloud/server/internal/ws" ) @@ -29,6 +30,16 @@ func main() { } else if geoip.Enabled() { log.Printf("GeoIP 数据库已启用: %s", cfg.GeoIP.DBPath) defer geoip.Close() + go func() { + updated, err := service.BackfillVisitorGeoIP(context.Background()) + if err != nil { + log.Printf("警告: 历史访客地区回填失败: %v", err) + return + } + if updated > 0 { + log.Printf("历史访客地区回填完成: %d 条", updated) + } + }() } // 初始化 JWT(有效期可用 JWT_EXPIRE_HOURS 配置,默认 24 小时) diff --git a/server/internal/service/visitor_geo.go b/server/internal/service/visitor_geo.go new file mode 100644 index 0000000..995fb31 --- /dev/null +++ b/server/internal/service/visitor_geo.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "net" + "strings" + + "kefu-cloud/server/internal/geoip" + "kefu-cloud/server/internal/model" +) + +const visitorGeoBackfillBatch = 500 + +var visitorGeoLookup = geoip.Lookup + +// BackfillVisitorGeoIP 使用历史会话保存的 IP 补齐旧数据中的国家/省市信息。 +func BackfillVisitorGeoIP(ctx context.Context) (int, error) { + var lastID uint + updated := 0 + for { + select { + case <-ctx.Done(): + return updated, ctx.Err() + default: + } + + var sessions []model.Session + if err := model.DB. + Select("id", "visitor_ip"). + Where("id > ? AND visitor_ip <> ''", lastID). + Where("visitor_country = '' AND (visitor_region = '' OR visitor_region = ?)", "未知"). + Order("id asc").Limit(visitorGeoBackfillBatch).Find(&sessions).Error; err != nil { + return updated, err + } + if len(sessions) == 0 { + return updated, nil + } + + for _, session := range sessions { + lastID = session.ID + location, ok := visitorGeoLookup(session.VisitorIP) + if !ok { + if isPrivateIP(session.VisitorIP) { + if err := model.DB.Model(&model.Session{}).Where("id = ?", session.ID). + Update("visitor_region", "内网").Error; err != nil { + return updated, err + } + updated++ + } + continue + } + if strings.TrimSpace(location.Region) == "" { + continue + } + if err := model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Updates(map[string]interface{}{ + "visitor_region": location.Region, + "visitor_country": location.Country, + "visitor_province": location.Province, + "visitor_city": location.City, + }).Error; err != nil { + return updated, err + } + updated++ + } + if len(sessions) < visitorGeoBackfillBatch { + return updated, nil + } + } +} + +func isPrivateIP(raw string) bool { + ip := net.ParseIP(strings.TrimSpace(raw)) + return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) +} diff --git a/server/internal/service/visitor_geo_test.go b/server/internal/service/visitor_geo_test.go new file mode 100644 index 0000000..d2db864 --- /dev/null +++ b/server/internal/service/visitor_geo_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "kefu-cloud/server/internal/geoip" + "kefu-cloud/server/internal/model" +) + +func TestBackfillVisitorGeoIP(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:kefu_geo_backfill_%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := model.Migrate(db); err != nil { + t.Fatal(err) + } + oldDB := model.DB + model.DB = db + t.Cleanup(func() { model.DB = oldDB }) + + originalLookup := visitorGeoLookup + visitorGeoLookup = func(ip string) (geoip.Location, bool) { + if ip == "152.136.121.51" { + return geoip.Location{ + Country: "中国", Province: "广东省", City: "深圳市", Region: "中国 广东省 深圳市", + }, true + } + return geoip.Location{}, false + } + t.Cleanup(func() { visitorGeoLookup = originalLookup }) + + sessions := []model.Session{ + {TenantID: 1, ChannelID: 1, CustomerID: 1, VisitorIP: "152.136.121.51", VisitorRegion: "未知", Status: "ended"}, + {TenantID: 1, ChannelID: 1, CustomerID: 2, VisitorIP: "192.168.1.10", VisitorRegion: "未知", Status: "ended"}, + } + if err := db.Create(&sessions).Error; err != nil { + t.Fatal(err) + } + + updated, err := BackfillVisitorGeoIP(context.Background()) + if err != nil { + t.Fatal(err) + } + if updated != 2 { + t.Fatalf("updated=%d, want 2", updated) + } + if err := db.First(&sessions[0], sessions[0].ID).Error; err != nil { + t.Fatal(err) + } + if sessions[0].VisitorRegion != "中国 广东省 深圳市" || + sessions[0].VisitorProvince != "广东省" || sessions[0].VisitorCity != "深圳市" { + t.Fatalf("公网 IP 未正确回填: %+v", sessions[0]) + } + if err := db.First(&sessions[1], sessions[1].ID).Error; err != nil { + t.Fatal(err) + } + if sessions[1].VisitorRegion != "内网" { + t.Fatalf("私网 IP 应标记内网: %+v", sessions[1]) + } +} diff --git a/web/public/widget.js b/web/public/widget.js index 9e77945..440a4b4 100644 --- a/web/public/widget.js +++ b/web/public/widget.js @@ -143,6 +143,7 @@ function setOpen(next) { open = next; if (open) { + markSessionStarted(); ensureIframe(); panel.style.display = 'block'; btn.style.display = 'none'; @@ -164,12 +165,25 @@ } function shouldAutoOpenOnce() { + if (hasStartedSession()) return false; + markSessionStarted(); + return true; + } + + function hasStartedSession() { + var key = 'kefu_widget_auto_opened_' + channelKey; + try { + return window.sessionStorage.getItem(key) === '1'; + } catch (e) { + return false; + } + } + + function markSessionStarted() { var key = 'kefu_widget_auto_opened_' + channelKey; try { - if (window.sessionStorage.getItem(key) === '1') return false; window.sessionStorage.setItem(key, '1'); - } catch (e) { /* privacy mode: fall back to opening on each page load */ } - return true; + } catch (e) { /* ignore */ } } btn.addEventListener('click', function () { setOpen(true); }); @@ -227,7 +241,10 @@ function mount() { document.body.appendChild(btn); document.body.appendChild(panel); - if (autoOpen) { + if (hasStartedSession()) { + // 同站后续页面保持隐藏 iframe 心跳和轨迹,不重复自动展开面板。 + ensureIframe(); + } else if (autoOpen) { window.setTimeout(function () { if (!open && shouldAutoOpenOnce()) setOpen(true); }, 600); diff --git a/web/src/pages/agent/dashboard/helpers.ts b/web/src/pages/agent/dashboard/helpers.ts index 7634df5..097520b 100644 --- a/web/src/pages/agent/dashboard/helpers.ts +++ b/web/src/pages/agent/dashboard/helpers.ts @@ -39,7 +39,7 @@ export const endReasons = [ { value: 'other', label: '其他' }, ] -/** 列表项展示:紧急 / 等待中 / 进行中 */ +/** 列表项展示:紧急 / 等待中 / 访客离线 / 进行中 */ export function listStatusMeta(session: Session) { if (session.priority === 'urgent') { return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' } @@ -47,6 +47,9 @@ export function listStatusMeta(session: Session) { if (session.status === 'waiting') { return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' } } + if (!isVisitorOnline(session)) { + return { label: '访客离线', bar: '#94a3b8', avatarBg: '#f8fafc', avatarColor: '#64748b', dot: '#94a3b8' } + } return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' } } diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 04b9a84..76012a2 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -537,7 +537,7 @@ const VisitorChat = ({ void reportPageView(url, title) }, [embedded, sessionId, visitorToken, sessionEnded, reportPageView]) - // 心跳:独立于聊天窗口展开状态,随宿主页可见性上报。 + // 心跳:独立于聊天窗口展开状态;页面跳转期间不立即离线,交给 45 秒超时判定。 useEffect(() => { if (!sessionId || !visitorToken || sessionEnded) return const sendPresence = (state: 'visible' | 'hidden' | 'leave', keepalive = false) => { @@ -556,15 +556,12 @@ const VisitorChat = ({ keepalive, }).catch(() => {}) } - sendPresence(hostVisible ? 'visible' : 'hidden') + if (hostVisible) sendPresence('visible') const t = hostVisible ? window.setInterval(() => sendPresence('visible'), 15000) : null - const onPageHide = () => sendPresence('leave', true) const onPageShow = () => { if (hostVisible) sendPresence('visible') } - window.addEventListener('pagehide', onPageHide) window.addEventListener('pageshow', onPageShow) return () => { if (t != null) window.clearInterval(t) - window.removeEventListener('pagehide', onPageHide) window.removeEventListener('pageshow', onPageShow) } }, [sessionId, visitorToken, sessionEnded, hostVisible, presencePulse])