fix(widget): 优化访客在线续接与历史地区回填
This commit is contained in:
@@ -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 小时)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -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);
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user