From 38d6fc1684326a252155fe6e8d2db36f0c1ba575 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 18:21:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=E4=BC=98=E5=8C=96=E8=AE=BF?= =?UTF-8?q?=E5=AE=A2=E5=9C=A8=E7=BA=BF=E8=BD=A8=E8=BF=B9=E3=80=81GeoIP?= =?UTF-8?q?=E4=B8=8E=E5=A4=9A=E7=AB=99=E7=82=B9=E6=B8=A0=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + .gitignore | 3 + README.md | 1 + deploy/.env.prod.example | 4 + deploy/README.md | 5 + deploy/geoip/README.md | 20 +++ deploy/scripts/deploy.sh | 30 ++++ docker-compose.prod.yml | 3 + docs/INTEGRATION_GUIDE.md | 3 + server/.env.example | 2 + server/cmd/main.go | 9 ++ server/go.mod | 2 + server/go.sum | 4 + server/internal/config/config.go | 8 + server/internal/geoip/geoip.go | 136 ++++++++++++++++ server/internal/geoip/geoip_test.go | 21 +++ server/internal/geoip/integration_test.go | 22 +++ server/internal/handler/channel.go | 48 ++++-- .../handler/security_integration_test.go | 151 ++++++++++++++++++ server/internal/handler/statistics.go | 27 ++-- server/internal/handler/visitor_meta.go | 66 +++++--- server/internal/handler/widget.go | 15 +- server/internal/model/models.go | 23 ++- server/internal/service/presence.go | 56 +++++++ server/internal/service/presence_test.go | 32 ++++ server/internal/service/session.go | 11 +- server/internal/service/site.go | 48 ++++++ server/internal/service/site_test.go | 30 ++++ server/internal/service/widget.go | 97 +++++++++-- web/public/widget.js | 33 +++- web/src/pages/agent/Dashboard.tsx | 73 ++++++--- web/src/pages/agent/Settings.tsx | 41 +++-- web/src/pages/agent/Statistics.tsx | 4 +- web/src/pages/agent/dashboard/helpers.ts | 20 +++ .../pages/agent/settings/ChannelsSection.tsx | 75 ++++++++- web/src/services/api.ts | 16 +- web/src/widgets/VisitorChat.tsx | 54 +++++-- 37 files changed, 1071 insertions(+), 123 deletions(-) create mode 100644 .dockerignore create mode 100644 deploy/geoip/README.md create mode 100644 server/internal/geoip/geoip.go create mode 100644 server/internal/geoip/geoip_test.go create mode 100644 server/internal/geoip/integration_test.go create mode 100644 server/internal/service/presence.go create mode 100644 server/internal/service/presence_test.go create mode 100644 server/internal/service/site.go create mode 100644 server/internal/service/site_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b0259a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +deploy/geoip/*.mmdb diff --git a/.gitignore b/.gitignore index 79e1ef3..84d1b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ server/bin/ # 测试证据截图(本地保留,不入库) gui-test-screenshots/ + +# GeoIP 数据库文件(按部署环境单独提供) +*.mmdb diff --git a/README.md b/README.md index 3ae0282..adab262 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ seed 仅初始化平台管理员与默认套餐,不预置任何虚构的租户 | `DB_HOST` / `DB_PORT` | `localhost` / `5433` | 数据库 | | `DB_USER` / `DB_PASSWORD` / `DB_NAME` | `postgres` / `postgres` / `kefu_cloud` | | | `JWT_SECRET` | 内置开发密钥 | **生产务必修改** | +| `GEOIP_DB_PATH` | 空 | 可选 MaxMind City MMDB;配置后解析国家/省市 | | `STORAGE_ENDPOINT` | `localhost:9100` | MinIO/S3 | | `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` | `minioadmin` | | | `STORAGE_BUCKET` | `kefu` | | diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index e29fc2c..3dd2780 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -30,6 +30,10 @@ DB_SSLMODE=disable # ========== JWT(deploy.sh 可自动生成)========== JWT_SECRET=请改成随机长字符串 CORS_ALLOW_ORIGINS=https://你的域名 +# 可选:离线 GeoIP City 数据库(挂载自 deploy/geoip/) +# GEOIP_DB_PATH=/app/geoip/GeoLite2-City.mmdb +# GEOIP_DB_FILE=./deploy/geoip/GeoLite2-City.mmdb +# GEOIP_DB_URL=https://raw.githubusercontent.com/P3TERX/GeoLite.mmdb/download/GeoLite2-City.mmdb # ========== MinIO 对象存储 ========== STORAGE_ACCESS_KEY=kefu_minio diff --git a/deploy/README.md b/deploy/README.md index be43d46..501a122 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -92,6 +92,7 @@ chmod +x deploy/scripts/*.sh | `STORAGE_PUBLIC_BASE_URL` | `https://kefu.xx.com/files` | **必须**与应用内 Nginx `/files` 一致 | | `HTTP_PORT` | `18080` | 宿主机映射端口;已有 Caddy 占 80 时用非 80 端口 | | `DB_PASSWORD` / `JWT_SECRET` / `STORAGE_*` | 强随机 | 脚本可自动生成密钥 | +| `GEOIP_DB_PATH` | `/app/geoip/GeoLite2-City.mmdb` | 可选离线 IP 城市库;文件放到 `deploy/geoip/` | 脚本会校验占位符是否改掉,并在 `HTTP_PORT` 已被占用时告警/退出。 @@ -161,6 +162,10 @@ STORAGE_PUBLIC_BASE_URL=https://kefu.example.com/files 生产链路由 Caddy → 应用 Nginx → API 时,API 优先读取 `X-Forwarded-For` 最左侧地址,`X-Real-IP` 仅作为回退,因此外部访客会显示其公网地址。请保持示例反代配置中的 `X-Forwarded-For` 转发,不要在边缘代理处清空该请求头。 +城市级地区使用 `deploy/geoip/GeoLite2-City.mmdb`。 +部署脚本会自动下载并每 30 天刷新该文件;可用 `GEOIP_DB_URL` 覆盖下载源。 +下载失败时只会使用 CDN 国家头,公网 IP 通常显示“未知”。 + 任选其一(与上一节「Caddy 共存」二选一即可): 1. **CLB / CDN** 终结 HTTPS,回源 CVM `HTTP_PORT`(或仍回源 Caddy) diff --git a/deploy/geoip/README.md b/deploy/geoip/README.md new file mode 100644 index 0000000..401d566 --- /dev/null +++ b/deploy/geoip/README.md @@ -0,0 +1,20 @@ +# GeoIP 数据库 + +部署脚本会在文件缺失或超过 30 天时自动下载: + +```text +deploy/geoip/GeoLite2-City.mmdb +``` + +Docker Compose 会把本目录只读挂载到 API 容器的 `/app/geoip`,默认读取: + +```text +GEOIP_DB_PATH=/app/geoip/GeoLite2-City.mmdb +``` + +可通过 `GEOIP_DB_URL` 覆盖下载源,或将自有 MaxMind GeoIP2 City 文件放到上述路径。 +下载失败时服务仍可正常启动,IP 地区会降级到 CDN 国家请求头或“未知”。 +更换数据库后重启 API 容器即可生效。 + +默认下载源是公开的 GeoLite2 镜像。正式商用请遵守 MaxMind GeoLite2 +许可与署名要求,或通过 `GEOIP_DB_URL` 指向自有授权下载地址。 diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index aad326f..785fff0 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -97,6 +97,35 @@ check_http_port() { fi } +ensure_geoip() { + local db_file="${GEOIP_DB_FILE:-deploy/geoip/GeoLite2-City.mmdb}" + local db_url="${GEOIP_DB_URL:-https://raw.githubusercontent.com/P3TERX/GeoLite.mmdb/download/GeoLite2-City.mmdb}" + local refresh=0 + + if [[ ! -s "$db_file" ]] || [[ "$(wc -c < "$db_file" | tr -d ' ')" -lt 1000000 ]] \ + || ! tail -c 131072 "$db_file" | grep -aq 'MaxMind.com'; then + refresh=1 + elif find "$db_file" -mtime +30 -print -quit 2>/dev/null | grep -q .; then + refresh=1 + fi + [[ "$refresh" -eq 0 ]] && return 0 + + need_cmd curl + mkdir -p "$(dirname "$db_file")" + local tmp="${db_file}.download.$$" + log "下载 GeoIP 城市库: $db_url" + rm -f "$tmp" + if curl -fL --retry 3 --connect-timeout 15 --max-time 300 -o "$tmp" "$db_url" \ + && [[ "$(wc -c < "$tmp" | tr -d ' ')" -gt 1000000 ]] \ + && tail -c 131072 "$tmp" | grep -aq 'MaxMind.com'; then + mv "$tmp" "$db_file" + log "GeoIP 数据库已更新: $db_file" + else + rm -f "$tmp" + warn "GeoIP 自动下载失败,服务将降级为 CDN 国家头/未知地区" + fi +} + ensure_env() { if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_EXAMPLE" ]]; then @@ -227,6 +256,7 @@ cmd_up() { fi ensure_env + ensure_geoip log "拉取基础镜像…" compose pull postgres minio minio-init || true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4936329..d6c3ba4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -104,6 +104,7 @@ services: DB_CONN_MAX_IDLE_MINUTES: ${DB_CONN_MAX_IDLE_MINUTES:-15} JWT_SECRET: ${JWT_SECRET:?请设置 JWT_SECRET} CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-*} + GEOIP_DB_PATH: ${GEOIP_DB_PATH:-/app/geoip/GeoLite2-City.mmdb} STORAGE_ENDPOINT: minio:9000 STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} @@ -121,6 +122,8 @@ services: REDIS_DB: ${REDIS_DB:-0} networks: - kefu_cloud_net + volumes: + - ./deploy/geoip:/app/geoip:ro # 不对外暴露 8080,统一走 nginx web: diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 2b06e60..b2894e7 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -15,6 +15,9 @@ > 💡 **获取方式**:登录客服工作台 -> 左侧菜单「系统设置」 -> 「渠道管理」 -> 复制您渠道的专属代码(其中的 `data-id` 即为您的 Channel Key)。 +每个独立网站应创建单独的网页渠道并绑定域名,不要在不同网站复用同一个 +`data-id`,否则工作台无法区分来源站点。 + --- ## 2. 快速接入(标准网页 / HTML) diff --git a/server/.env.example b/server/.env.example index 9626747..d33290e 100644 --- a/server/.env.example +++ b/server/.env.example @@ -10,3 +10,5 @@ DB_SSLMODE=disable JWT_SECRET=kefu-cloud-dev-secret-key CORS_ALLOW_ORIGINS=* +# 可选:MaxMind GeoLite2/GeoIP2 City MMDB 文件 +# GEOIP_DB_PATH=/path/to/GeoLite2-City.mmdb diff --git a/server/cmd/main.go b/server/cmd/main.go index 50bb6ca..18d5da9 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "kefu-cloud/server/internal/config" + "kefu-cloud/server/internal/geoip" "kefu-cloud/server/internal/handler" "kefu-cloud/server/internal/metrics" "kefu-cloud/server/internal/middleware" @@ -22,6 +23,14 @@ func main() { // 初始化数据库 model.InitDB(cfg.Database.DSN()) + // 可选 GeoIP 离线库;未配置或文件不存在时自动降级到 CDN 国家头/未知。 + if err := geoip.Init(cfg.GeoIP.DBPath); err != nil { + log.Printf("警告: GeoIP 数据库未启用: %v", err) + } else if geoip.Enabled() { + log.Printf("GeoIP 数据库已启用: %s", cfg.GeoIP.DBPath) + defer geoip.Close() + } + // 初始化 JWT(有效期可用 JWT_EXPIRE_HOURS 配置,默认 24 小时) middleware.InitJWT(cfg.JWT.Secret, cfg.JWT.ExpireTime) diff --git a/server/go.mod b/server/go.mod index 94826b4..fae9d22 100644 --- a/server/go.mod +++ b/server/go.mod @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gorilla/websocket v1.5.3 github.com/minio/minio-go/v7 v7.2.1 + github.com/oschwald/geoip2-golang v1.11.0 github.com/redis/go-redis/v9 v9.7.0 github.com/shirou/gopsutil/v4 v4.25.1 golang.org/x/crypto v0.51.0 @@ -53,6 +54,7 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect diff --git a/server/go.sum b/server/go.sum index 97e3ff8..1eb8a55 100644 --- a/server/go.sum +++ b/server/go.sum @@ -100,6 +100,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= +github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 7b26efd..4613dac 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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", ""), + }, } } diff --git a/server/internal/geoip/geoip.go b/server/internal/geoip/geoip.go new file mode 100644 index 0000000..5626d48 --- /dev/null +++ b/server/internal/geoip/geoip.go @@ -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, " ") +} diff --git a/server/internal/geoip/geoip_test.go b/server/internal/geoip/geoip_test.go new file mode 100644 index 0000000..b7c8c44 --- /dev/null +++ b/server/internal/geoip/geoip_test.go @@ -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) + } +} diff --git a/server/internal/geoip/integration_test.go b/server/internal/geoip/integration_test.go new file mode 100644 index 0000000..1d9e73d --- /dev/null +++ b/server/internal/geoip/integration_test.go @@ -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) + } +} diff --git a/server/internal/handler/channel.go b/server/internal/handler/channel.go index ff52278..937d64c 100644 --- a/server/internal/handler/channel.go +++ b/server/internal/handler/channel.go @@ -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" diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index dde1ea7..3383ae9 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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: ``, + } + 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") diff --git a/server/internal/handler/statistics.go b/server/internal/handler/statistics.go index db0b3b4..85438b5 100644 --- a/server/internal/handler/statistics.go +++ b/server/internal/handler/statistics.go @@ -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) } diff --git a/server/internal/handler/visitor_meta.go b/server/internal/handler/visitor_meta.go index 027e8c8..9ee28f4 100644 --- a/server/internal/handler/visitor_meta.go +++ b/server/internal/handler/visitor_meta.go @@ -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), + } } diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 038ac68..c1caed9 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -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 { diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 4c17d1d..5c6528e 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -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 { diff --git a/server/internal/service/presence.go b/server/internal/service/presence.go new file mode 100644 index 0000000..a2dec39 --- /dev/null +++ b/server/internal/service/presence.go @@ -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) + } +} diff --git a/server/internal/service/presence_test.go b/server/internal/service/presence_test.go new file mode 100644 index 0000000..4253289 --- /dev/null +++ b/server/internal/service/presence_test.go @@ -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("已结束会话不应在线") + } +} diff --git a/server/internal/service/session.go b/server/internal/service/session.go index cc4c8d9..feb12da 100644 --- a/server/internal/service/session.go +++ b/server/internal/service/session.go @@ -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 diff --git a/server/internal/service/site.go b/server/internal/service/site.go new file mode 100644 index 0000000..3611af6 --- /dev/null +++ b/server/internal/service/site.go @@ -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.") +} diff --git a/server/internal/service/site_test.go b/server/internal/service/site_test.go new file mode 100644 index 0000000..9eeb860 --- /dev/null +++ b/server/internal/service/site_test.go @@ -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("未配置域名时应允许任意站点") + } +} diff --git a/server/internal/service/widget.go b/server/internal/service/widget.go index 483be6b..b97d74e 100644 --- a/server/internal/service/widget.go +++ b/server/internal/service/widget.go @@ -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(¤t).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(¤t).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(¤t).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 访客发送消息;排队中会尝试自动分配并广播。 diff --git a/web/public/widget.js b/web/public/widget.js index 371979a..9e77945 100644 --- a/web/public/widget.js +++ b/web/public/widget.js @@ -34,6 +34,7 @@ var lastSentKey = ''; var lastSentAt = 0; var routeTimer = null; + var presenceTimer = null; var btn = document.createElement('button'); btn.type = 'button'; @@ -104,7 +105,7 @@ * 避免轨迹标题永远慢一页(URL 已是新页、title 仍是上一页)。 */ function schedulePostPage(force) { - if (!open && !force) return; + if (!iframe && !force) return; if (routeTimer) clearTimeout(routeTimer); routeTimer = setTimeout(function () { routeTimer = null; @@ -123,8 +124,20 @@ iframe.src = buildEmbedURL(); iframe.addEventListener('load', function () { schedulePostPage(true); + postHostVisibility(); }); panel.appendChild(iframe); + if (presenceTimer == null) { + presenceTimer = window.setInterval(function () { + if (!iframe || !iframe.contentWindow) return; + try { + iframe.contentWindow.postMessage({ + type: 'kefu-host-tick', + visible: document.visibilityState === 'visible', + }, '*'); + } catch (e) { /* ignore */ } + }, 15000); + } } function setOpen(next) { @@ -140,6 +153,16 @@ } } + function postHostVisibility() { + if (!iframe || !iframe.contentWindow) return; + try { + iframe.contentWindow.postMessage({ + type: 'kefu-host-visibility', + visible: document.visibilityState === 'visible', + }, '*'); + } catch (e) { /* ignore */ } + } + function shouldAutoOpenOnce() { var key = 'kefu_widget_auto_opened_' + channelKey; try { @@ -158,13 +181,14 @@ } // iframe 就绪后可再次同步宿主页 if (event.data.type === 'kefu-widget-ready') { + postHostVisibility(); schedulePostPage(true); } }); // —— SPA / 浏览器导航监听 —— function onRouteMaybeChanged() { - if (!open) return; + if (!iframe) return; schedulePostPage(false); } @@ -185,6 +209,11 @@ window.addEventListener('popstate', onRouteMaybeChanged); window.addEventListener('hashchange', onRouteMaybeChanged); + document.addEventListener('visibilitychange', function () { + postHostVisibility(); + if (document.visibilityState === 'visible') schedulePostPage(true); + }); + window.addEventListener('pagehide', postHostVisibility); // 标题可能异步更新 try { var titleEl = document.querySelector('title'); diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 7c9ecfd..6865fc2 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -19,8 +19,8 @@ import { type Session, type SessionEvent, type VisitorPageView, } from '@/services/api' import { - channelLabel, formatOnlineDuration, listStatusMeta, maxMessageSeq, mergeMessagesBySeq, normalizeMessage, - relativeTime, shortPagePath, + channelLabel, formatSeconds, isVisitorOnline, listStatusMeta, maxMessageSeq, mergeMessagesBySeq, + normalizeMessage, relativeTime, shortPagePath, type CustomerEditableField, type SessionDetail, } from './dashboard/helpers' import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields' @@ -73,7 +73,7 @@ const Dashboard = () => { const [savingCustomerField, setSavingCustomerField] = useState(null) const [savingCustomerTags, setSavingCustomerTags] = useState(false) /** 驱动在线读秒每秒刷新 */ - const [clockTick, setClockTick] = useState(0) + const [, setClockTick] = useState(0) const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) @@ -426,6 +426,25 @@ const Dashboard = () => { if (sameSession) void loadDetailRef.current(sid, false, { silent: true }) } + if (payload.type === 'visitor_presence') { + const presence = payload.data as unknown as { + visitor_online?: boolean + last_seen_at?: string | null + visitor_left_at?: string | null + } + setSessions(previous => previous.map(session => + session.id === sid + ? { + ...session, + visitor_online: Boolean(presence.visitor_online), + last_seen_at: presence.last_seen_at ?? session.last_seen_at, + visitor_left_at: presence.visitor_left_at ?? null, + } + : session, + )) + return + } + if (payload.type === 'page_view' && sameSession && payload.data) { const raw = payload.data as unknown as VisitorPageView & { title_fix?: boolean } if (raw?.url) { @@ -663,19 +682,24 @@ const Dashboard = () => { const selected = sessions.find(session => session.id === selectedId) const selectedCustomer = selected ? customers[selected.customer_id] : null + const visitorOnline = selected ? isVisitorOnline(selected) : false const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') - // 进行中会话:在线时长每秒刷新;轨迹每 8s 静默拉一次(防 WS 漏推) + // 在线/离线超时和相对时间需要持续刷新。 + useEffect(() => { + const t = window.setInterval(() => setClockTick(n => n + 1), 1000) + return () => clearInterval(t) + }, []) + + // 进行中会话:轨迹每 8s 静默拉一次(防 WS 漏推) useEffect(() => { if (!selected || selected.status === 'ended' || selected.status === 'archived') return - const t = window.setInterval(() => setClockTick(n => n + 1), 1000) const poll = window.setInterval(() => { if (selectedIdRef.current != null) { void loadDetailRef.current(selectedIdRef.current, false, { silent: true }) } }, 8000) return () => { - clearInterval(t) clearInterval(poll) } }, [selected?.id, selected?.status]) @@ -1190,12 +1214,20 @@ const Dashboard = () => {
{selectedCustomer.name} - - {channelLabel(selectedCustomer.source)} + + {selected.channel_name || channelLabel(selectedCustomer.source)} + {selected.site_host ? ` · ${selected.site_host}` : ''} - - - {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'} + + + {visitorOnline ? '在线' : '离线'}
{/* IP | 地区 | 在线时长 */} @@ -1208,12 +1240,8 @@ const Dashboard = () => { {selected.visitor_region || '未知地区'} · - - 在线 {formatOnlineDuration( - selected.created_at, - selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null, - clockTick >= 0 ? Date.now() : Date.now(), - )} + + {visitorOnline ? '最后心跳' : '最后活跃'} {relativeTime(selected.last_seen_at) || '—'}
@@ -1561,9 +1589,9 @@ const Dashboard = () => {
- {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'} + {visitorOnline ? '在线' : '离线'} {selectedCustomer.source ? ( · {selectedCustomer.source} ) : null} @@ -1653,9 +1681,14 @@ const Dashboard = () => { {new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - {idx === 0 && ( + {idx === 0 && visitorOnline && !pv.exited_at && ( 当前 )} + {!pv.exited_at && idx === 0 && visitorOnline ? null : ( + + {formatSeconds(pv.duration_seconds) || '—'} + + )}
{ } } - const ensureWebChannel = async () => { + const addWebChannel = async () => { + const count = channels.filter(channel => channel.type === 'web').length + 1 try { - await createChannel({ type: 'web', name: '网页聊天' }) - message.success('已创建网页渠道') + await createChannel({ type: 'web', name: `网站 ${count}` }) + message.success('已添加网站渠道') await loadChannels() } catch (e) { message.error(e instanceof Error ? e.message : '创建失败') } } + const updateChannelMeta = async (id: number, data: { name?: string; site_domain?: string }) => { + try { + const res = await updateChannel(id, data) + setChannels(prev => prev.map(channel => channel.id === id ? res.data : channel)) + message.success('网站渠道已更新') + } catch (e) { + message.error(e instanceof Error ? e.message : '更新失败') + throw e + } + } + const addChannelType = async (t: string) => { try { await createChannel({ type: t, name: typeMeta[t].label }) @@ -288,14 +300,18 @@ const Settings = () => { const missingTypes = Object.keys(typeMeta).filter(t => !channels.some(c => c.type === t)) - const panelHeaderAction = activeTab === 'channels' && missingTypes.includes('web') ? ( - - ) : activeTab === 'channels' && missingTypes.length > 0 ? ( - + const otherMissingTypes = missingTypes.filter(t => t !== 'web') + const panelHeaderAction = activeTab === 'channels' ? ( +
+ {otherMissingTypes.length > 0 && ( + + )} + +
) : null const currentGroup = visibleTabGroups.find(g => g.items.some(i => i.key === activeTab)) @@ -391,8 +407,9 @@ const Settings = () => { missingTypes={missingTypes} togglingId={togglingId} toggleChannel={toggleChannel} - ensureWebChannel={ensureWebChannel} + ensureWebChannel={addWebChannel} addChannelType={addChannelType} + updateChannelMeta={updateChannelMeta} /> )} diff --git a/web/src/pages/agent/Statistics.tsx b/web/src/pages/agent/Statistics.tsx index 751c345..cf02906 100644 --- a/web/src/pages/agent/Statistics.tsx +++ b/web/src/pages/agent/Statistics.tsx @@ -327,9 +327,9 @@ const Statistics = () => {
-

渠道来源占比

+

网站 / 渠道来源占比

- 各渠道进线分布(共 {channelTotal} 次) + 各网站与渠道的进线分布(共 {channelTotal} 次)

{channelData.length === 0 || channelTotal === 0 ? ( diff --git a/web/src/pages/agent/dashboard/helpers.ts b/web/src/pages/agent/dashboard/helpers.ts index 47ce025..7634df5 100644 --- a/web/src/pages/agent/dashboard/helpers.ts +++ b/web/src/pages/agent/dashboard/helpers.ts @@ -130,6 +130,19 @@ export interface SessionDetail { pendingCount: number } +export const VISITOR_ONLINE_TIMEOUT_MS = 45_000 + +export function isVisitorOnline(session: Session, nowMs = Date.now()): boolean { + if (session.status === 'ended' || session.status === 'archived' || !session.last_seen_at) return false + const lastSeen = new Date(session.last_seen_at).getTime() + if (!Number.isFinite(lastSeen) || nowMs - lastSeen > VISITOR_ONLINE_TIMEOUT_MS) return false + if (session.visitor_left_at) { + const leftAt = new Date(session.visitor_left_at).getTime() + if (Number.isFinite(leftAt) && leftAt >= lastSeen) return false + } + return true +} + export function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string { if (!startIso) return '—' const start = new Date(startIso).getTime() @@ -144,6 +157,13 @@ export function formatOnlineDuration(startIso?: string | null, endIso?: string | return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` } +export function formatSeconds(seconds?: number | null): string { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '' + const minutes = Math.floor(seconds / 60) + const rest = Math.floor(seconds % 60) + return minutes > 0 ? `${minutes}分${rest}秒` : `${rest}秒` +} + export function shortPagePath(url?: string): string { if (!url) return '—' try { diff --git a/web/src/pages/agent/settings/ChannelsSection.tsx b/web/src/pages/agent/settings/ChannelsSection.tsx index bbf483f..85ff3a9 100644 --- a/web/src/pages/agent/settings/ChannelsSection.tsx +++ b/web/src/pages/agent/settings/ChannelsSection.tsx @@ -1,5 +1,6 @@ -import { Button, Empty, Spin, Switch, message } from 'antd' -import { CopyOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons' +import { useState } from 'react' +import { Button, Empty, Form, Input, Modal, Spin, Switch, message } from 'antd' +import { CopyOutlined, EditOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons' import type { Channel } from '@/services/api' import { typeMeta } from './channelMeta' @@ -25,6 +26,7 @@ interface ChannelsSectionProps { toggleChannel: (ch: Channel, enabled: boolean) => void ensureWebChannel: () => void addChannelType: (t: string) => void + updateChannelMeta: (id: number, data: { name?: string; site_domain?: string }) => Promise } /** 渠道管理:接入渠道列表与嵌入代码 */ @@ -36,7 +38,32 @@ const ChannelsSection = ({ toggleChannel, ensureWebChannel, addChannelType, + updateChannelMeta, }: ChannelsSectionProps) => { + const [form] = Form.useForm() + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + + const saveSite = async () => { + if (!editing) return + let values: { name?: string; site_domain?: string } + try { + values = await form.validateFields() + } catch { + return + } + setSaving(true) + try { + await updateChannelMeta(editing.id, { + name: String(values.name || '').trim(), + site_domain: String(values.site_domain || '').trim(), + }) + setEditing(null) + } finally { + setSaving(false) + } + } + return (
{loadingChannels ? ( @@ -88,6 +115,23 @@ const ChannelsSection = ({ onChange={v => toggleChannel(ch, v)} />
+ {ch.type === 'web' && ( +
+ 绑定域名 + {ch.site_domain || '未绑定,可被任意网站使用'} +
+ )} {meta.desc && (

{meta.desc}

)} @@ -145,6 +189,33 @@ const ChannelsSection = ({
)} + + setEditing(null)} + onOk={() => { void saveSite() }} + confirmLoading={saving} + okText="保存" + cancelText="取消" + > +
+ + + + + + +
+
) } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index b8ff485..6cee609 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -20,6 +20,13 @@ export interface Session { end_reason?: string visitor_ip?: string visitor_region?: string + visitor_country?: string + visitor_province?: string + visitor_city?: string + site_host?: string + /** 服务端按心跳动态计算 */ + visitor_online?: boolean + visitor_left_at?: string | null user_agent?: string /** 访客设备指纹(用于设备拉黑) */ device_key?: string @@ -39,6 +46,7 @@ export interface Session { agent_name?: string channel_name?: string channel_type?: string + channel_domain?: string } export interface CustomerContact { @@ -58,6 +66,8 @@ export interface VisitorPageView { url: string title: string entered_at: string + exited_at?: string | null + duration_seconds?: number } export interface Message { @@ -119,7 +129,7 @@ export interface KnowledgeEntry { export interface Channel { id: number; tenant_id: number; type: string; name: string; status: string - config: string; script_code: string; channel_key: string + config: string; script_code: string; channel_key: string; site_domain?: string } export interface Tenant { @@ -567,8 +577,8 @@ export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflic // Channels export const getChannels = () => get('/channels') -export const createChannel = (data: { type: string; name?: string }) => post('/channels', data) -export const updateChannel = (id: number, data: { name?: string; status?: string }) => put(`/channels/${id}`, data) +export const createChannel = (data: { type: string; name?: string; site_domain?: string }) => post('/channels', data) +export const updateChannel = (id: number, data: { name?: string; status?: string; site_domain?: string }) => put(`/channels/${id}`, data) // 坐席账号 export interface StaffUser { diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index df5bcd6..04b9a84 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -71,6 +71,10 @@ const VisitorChat = ({ const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null) const [sendError, setSendError] = useState('') const [agentsOnline, setAgentsOnline] = useState(true) + const [hostVisible, setHostVisible] = useState( + () => !embedded || typeof document === 'undefined' || document.visibilityState === 'visible', + ) + const [presencePulse, setPresencePulse] = useState(0) const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。') const [welcomeSegments, setWelcomeSegments] = useState(defaultWelcomeSegments) const [displayName, setDisplayName] = useState('在线客服') @@ -490,7 +494,17 @@ const VisitorChat = ({ } catch { /* ignore */ } const onMsg = (event: MessageEvent) => { const data = event?.data - if (!data || data.type !== 'kefu-host-page') return + if (!data) return + if (data.type === 'kefu-host-visibility') { + setHostVisible(Boolean(data.visible)) + return + } + if (data.type === 'kefu-host-tick') { + setHostVisible(Boolean(data.visible)) + setPresencePulse(value => value + 1) + return + } + if (data.type !== 'kefu-host-page') return const url = typeof data.url === 'string' ? data.url : '' const title = typeof data.title === 'string' ? data.title : '' const referrer = typeof data.referrer === 'string' ? data.referrer : hostPageRef.current.referrer @@ -502,6 +516,14 @@ const VisitorChat = ({ return () => window.removeEventListener('message', onMsg) }, [embedded, reportPageView]) + useEffect(() => { + if (embedded) return + const onVisibility = () => setHostVisible(document.visibilityState === 'visible') + onVisibility() + document.addEventListener('visibilitychange', onVisibility) + return () => document.removeEventListener('visibilitychange', onVisibility) + }, [embedded]) + // 非嵌入预览:用当前页作为来源 useEffect(() => { if (embedded || !sessionId || !visitorToken || sessionEnded) return @@ -515,23 +537,37 @@ const VisitorChat = ({ void reportPageView(url, title) }, [embedded, sessionId, visitorToken, sessionEnded, reportPageView]) - // 心跳:刷新 last_seen_at,供客服端在线读秒 + // 心跳:独立于聊天窗口展开状态,随宿主页可见性上报。 useEffect(() => { - if (!sessionId || !visitorToken || sessionEnded || !open) return - const beat = () => { + if (!sessionId || !visitorToken || sessionEnded) return + const sendPresence = (state: 'visible' | 'hidden' | 'leave', keepalive = false) => { + const body = JSON.stringify({ session_id: sessionId, visitor_token: visitorToken, state }) + if (keepalive && typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon('/api/widget/heartbeat', new Blob([body], { type: 'application/json' })) + return + } void fetch('/api/widget/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken, }, - body: JSON.stringify({ session_id: sessionId, visitor_token: visitorToken }), + body, + keepalive, }).catch(() => {}) } - beat() - const t = window.setInterval(beat, 20000) - return () => clearInterval(t) - }, [sessionId, visitorToken, sessionEnded, open]) + sendPresence(hostVisible ? 'visible' : 'hidden') + 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]) // 兜底轮询:按 after_seq 增量对齐 useEffect(() => {