feat(widget): 优化访客在线轨迹、GeoIP与多站点渠道
This commit is contained in:
@@ -0,0 +1 @@
|
||||
deploy/geoip/*.mmdb
|
||||
@@ -17,3 +17,6 @@ server/bin/
|
||||
|
||||
# 测试证据截图(本地保留,不入库)
|
||||
gui-test-screenshots/
|
||||
|
||||
# GeoIP 数据库文件(按部署环境单独提供)
|
||||
*.mmdb
|
||||
|
||||
@@ -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` | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` 指向自有授权下载地址。
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
> 💡 **获取方式**:登录客服工作台 -> 左侧菜单「系统设置」 -> 「渠道管理」 -> 复制您渠道的专属代码(其中的 `data-id` 即为您的 Channel Key)。
|
||||
|
||||
每个独立网站应创建单独的网页渠道并绑定域名,不要在不同网站复用同一个
|
||||
`data-id`,否则工作台无法区分来源站点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 快速接入(标准网页 / HTML)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("已结束会话不应在线")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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("未配置域名时应允许任意站点")
|
||||
}
|
||||
}
|
||||
@@ -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 访客发送消息;排队中会尝试自动分配并广播。
|
||||
|
||||
+31
-2
@@ -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');
|
||||
|
||||
@@ -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<CustomerEditableField | null>(null)
|
||||
const [savingCustomerTags, setSavingCustomerTags] = useState(false)
|
||||
/** 驱动在线读秒每秒刷新 */
|
||||
const [clockTick, setClockTick] = useState(0)
|
||||
const [, setClockTick] = useState(0)
|
||||
const initialLoad = useRef(true)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 = () => {
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-bold text-[14px] text-slate-900">{selectedCustomer.name}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-cyan-50 text-cyan-700 border border-cyan-100">
|
||||
{channelLabel(selectedCustomer.source)}
|
||||
<span
|
||||
className="inline-flex items-center max-w-[260px] truncate px-2 py-0.5 rounded-md text-[11px] font-medium bg-cyan-50 text-cyan-700 border border-cyan-100"
|
||||
title={[selected.channel_name, selected.site_host || selected.channel_domain].filter(Boolean).join(' · ')}
|
||||
>
|
||||
{selected.channel_name || channelLabel(selectedCustomer.source)}
|
||||
{selected.site_host ? ` · ${selected.site_host}` : ''}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium border ${
|
||||
visitorOnline
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
|
||||
: 'bg-slate-100 text-slate-500 border-slate-200'
|
||||
}`}>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${visitorOnline ? 'bg-emerald-500' : 'bg-slate-400'}`} />
|
||||
{visitorOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
{/* IP | 地区 | 在线时长 */}
|
||||
@@ -1208,12 +1240,8 @@ const Dashboard = () => {
|
||||
{selected.visitor_region || '未知地区'}
|
||||
</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="tabular-nums shrink-0" title="访客在线时长(自进线起)">
|
||||
在线 {formatOnlineDuration(
|
||||
selected.created_at,
|
||||
selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null,
|
||||
clockTick >= 0 ? Date.now() : Date.now(),
|
||||
)}
|
||||
<span className="tabular-nums shrink-0" title="访客最后心跳时间">
|
||||
{visitorOnline ? '最后心跳' : '最后活跃'} {relativeTime(selected.last_seen_at) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1561,9 +1589,9 @@ const Dashboard = () => {
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-slate-400">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: selectedCustomer.status === 'online' ? '#16a34a' : '#94a3b8' }}
|
||||
style={{ background: visitorOnline ? '#16a34a' : '#94a3b8' }}
|
||||
/>
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
{visitorOnline ? '在线' : '离线'}
|
||||
{selectedCustomer.source ? (
|
||||
<span className="text-slate-300">· {selectedCustomer.source}</span>
|
||||
) : null}
|
||||
@@ -1653,9 +1681,14 @@ const Dashboard = () => {
|
||||
<span className="text-slate-400 tabular-nums shrink-0 text-[10px]">
|
||||
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
{idx === 0 && visitorOnline && !pv.exited_at && (
|
||||
<span className="text-[10px] text-blue-600 font-semibold">当前</span>
|
||||
)}
|
||||
{!pv.exited_at && idx === 0 && visitorOnline ? null : (
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{formatSeconds(pv.duration_seconds) || '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={pv.url}
|
||||
|
||||
@@ -266,16 +266,28 @@ const Settings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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') ? (
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={ensureWebChannel}>
|
||||
开通网页渠道
|
||||
</Button>
|
||||
) : activeTab === 'channels' && missingTypes.length > 0 ? (
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={() => addChannelType(missingTypes[0])}>
|
||||
添加新渠道
|
||||
</Button>
|
||||
const otherMissingTypes = missingTypes.filter(t => t !== 'web')
|
||||
const panelHeaderAction = activeTab === 'channels' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{otherMissingTypes.length > 0 && (
|
||||
<Button size="small" icon={<PlusOutlined />} className="!h-8" onClick={() => addChannelType(otherMissingTypes[0])}>
|
||||
添加{typeMeta[otherMissingTypes[0]].label}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={addWebChannel}>
|
||||
{channels.some(channel => channel.type === 'web') ? '添加网站' : '开通网页渠道'}
|
||||
</Button>
|
||||
</div>
|
||||
) : 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -327,9 +327,9 @@ const Statistics = () => {
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 m-0">渠道来源占比</h3>
|
||||
<h3 className="text-sm font-bold text-slate-800 m-0">网站 / 渠道来源占比</h3>
|
||||
<p className="text-xs text-slate-400 m-0 mt-0.5">
|
||||
各渠道进线分布(共 {channelTotal} 次)
|
||||
各网站与渠道的进线分布(共 {channelTotal} 次)
|
||||
</p>
|
||||
</div>
|
||||
{channelData.length === 0 || channelTotal === 0 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/** 渠道管理:接入渠道列表与嵌入代码 */
|
||||
@@ -36,7 +38,32 @@ const ChannelsSection = ({
|
||||
toggleChannel,
|
||||
ensureWebChannel,
|
||||
addChannelType,
|
||||
updateChannelMeta,
|
||||
}: ChannelsSectionProps) => {
|
||||
const [form] = Form.useForm()
|
||||
const [editing, setEditing] = useState<Channel | null>(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 (
|
||||
<div>
|
||||
{loadingChannels ? (
|
||||
@@ -88,6 +115,23 @@ const ChannelsSection = ({
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
</div>
|
||||
{ch.type === 'web' && (
|
||||
<div className="flex items-center gap-2 text-xs mb-2">
|
||||
<span className="text-slate-400 font-medium">绑定域名</span>
|
||||
<span className="text-slate-600">{ch.site_domain || '未绑定,可被任意网站使用'}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
className="!h-6 !w-6 !p-0"
|
||||
title="修改网站名称和域名"
|
||||
onClick={() => {
|
||||
setEditing(ch)
|
||||
form.setFieldsValue({ name: ch.name, site_domain: ch.site_domain || '' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{meta.desc && (
|
||||
<p className="text-xs text-slate-400 m-0 mb-3">{meta.desc}</p>
|
||||
)}
|
||||
@@ -145,6 +189,33 @@ const ChannelsSection = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="网站渠道设置"
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => { void saveSite() }}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" className="pt-3">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="网站名称"
|
||||
rules={[{ required: true, message: '请输入网站名称' }, { max: 50 }]}
|
||||
>
|
||||
<Input placeholder="如:官网、商城、活动落地页" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="site_domain"
|
||||
label="绑定域名"
|
||||
extra="填写后,该渠道只能从对应域名初始化;留空时不限制域名。支持 example.com 或 www.example.com。"
|
||||
>
|
||||
<Input placeholder="www.example.com" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-3
@@ -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<Channel[]>('/channels')
|
||||
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
|
||||
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
|
||||
export const createChannel = (data: { type: string; name?: string; site_domain?: string }) => post<Channel>('/channels', data)
|
||||
export const updateChannel = (id: number, data: { name?: string; status?: string; site_domain?: string }) => put<Channel>(`/channels/${id}`, data)
|
||||
|
||||
// 坐席账号
|
||||
export interface StaffUser {
|
||||
|
||||
@@ -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<WelcomeSegment[]>(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(() => {
|
||||
|
||||
Reference in New Issue
Block a user